diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..80529927 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,79 @@ +# Trinity XAI — Agent and Developer Guide + +**Purpose:** Entry point for human developers and AI coding agents working in the Trinity repository. Read this file first, then the relevant subsystem documents. This is a living repository-root guide, not a replacement for current source code. + +## 1. Project orientation + +Trinity is an explainable AI and analytical visualization desktop application centered on high-dimensional `FeatureVector`/`FeatureCollection` data, interactive JavaFX 3D SubScenes, and supporting 2D floating overlays. It includes Hyperspace, Hypersurface, Projections/Manifolds, Context Graph 3D, Hyperdrive ingestion/embeddings/topic discovery, and related analysis tools. + +The Hyperdrive developer guide identifies Java 25/JavaFX as the current target. The public README describes broader Java compatibility and Maven/Gradle build entry points. **Check the active checkout's build configuration and toolchain before choosing language APIs or build commands.** Do not assume older source snapshots reflect the current runtime. + +Start with `README.md` for product orientation and existing build/runtime guidance. Consult the actual Maven/Gradle build files and repository scripts for setup and launch instructions; do not assume a separate developer quick-start document exists. + +## 2. Repository-root documentation index + +| Document | Read when | Contents | +|---|---|---| +| [`README.md`](README.md) | Entering the project | Product overview, 3D projections, FeatureVector/FeatureCollection, build/runtime guidance | +| [`HYPERDRIVE_DEVELOPER_CONTEXT.md`](HYPERDRIVE_DEVELOPER_CONTEXT.md) | Working on Hyperdrive | Accepted ingestion, embeddings, REST, JSON mapping, topic discovery, landmarks, events, overlays and testing contracts | +| [`HYPERDRIVE_ROADMAP.md`](HYPERDRIVE_ROADMAP.md) | Planning Hyperdrive extensions | Proposed confidence/abstention, topic quality, persistence, hierarchy, feedback, semantic search and other priorities | +| [`CONTEXT_GRAPH_3D_DEVELOPER_CONTEXT.md`](CONTEXT_GRAPH_3D_DEVELOPER_CONTEXT.md) | Working on Context Graph 3D | Graph architecture, shared-mesh rendering, measured performance, camera/controls, interaction status and phased node optimization | + +**Naming:** Place the uploaded Hyperdrive documents at the canonical root filenames shown above (remove upload suffixes such as `(1)`). These links are intended to be relative and work in a GitHub checkout. Add future subsystem guides to this table as they are accepted. A dedicated Context Graph roadmap may be added later; do not present one as existing until created. + +## 3. Authority and change control + +1. **Current checkout is authoritative.** Ask for the latest source snapshot if working outside the repository. Generated patches, prior chat summaries and historical ZIP archives may predate user-applied fixes. +2. **Preserve verified behavior.** Do not silently regress working camera controls, rendering performance, request throttling, data integrity, or UI interactions to simplify a new feature. +3. **Separate implemented, verified, proposed and experimental.** A roadmap item is not a shipped feature. A generated source file is not a tested change. +4. **Work in approved phases.** Discuss architecture and compatibility before large changes. Do not expand scope across subsystems without agreement. +5. **Prefer minimal deltas.** Avoid broad rewrites, framework changes, new dependencies or unrelated cleanup in a focused fix. + +## 4. Coding conventions + +- Use explicit Java imports; **no wildcard imports**. +- Prefer reusable top-level classes over inner classes for models, mapping rows, payloads and controllers. +- Do not use `Node.userData` for domain objects, picking, or scene-graph mappings. Use explicit typed maps/registries and JavaFX event handlers. +- Avoid `Consumer`-driven controller APIs when explicit methods and event contracts are clearer. +- Do not introduce arbitrary dataset-size thresholds or caps without a measured requirement. +- Prefer established Trinity utilities, metrics, events, data models and UI conventions over parallel implementations. +- Avoid inline CSS; use existing stylesheets and style classes. +- Keep source identity and metadata intact when converting, projecting, classifying or displaying data. + +## 5. JavaFX architecture and concurrency + +- Heavy parsing, clustering, layout computation, REST calls and geometry preparation belong off the JavaFX Application Thread, usually in a `Task` or the established worker pattern. +- Constructing detached `TriangleMesh` geometry off the FX thread is supported by the project's established design; do not incorrectly insist all mesh construction must occur on the FX thread. +- Attach/mutate active scene-graph nodes and controls on the FX thread. Use `Platform.runLater()` or the existing pulse-based attachment approach as appropriate. +- Preserve cancellation and generation checks so stale background results cannot overwrite a newer view. +- Progress indicators and camera interaction must remain responsive during large operations. Measure pulse gaps and time to responsiveness, not only CPU construction duration. +- Keep domain calculations out of UI controls; communicate between independently managed panes through the relevant event system. +- SubScene controls belong in existing right-click menus and/or `LitPathPane` floating overlays, not arbitrary embedded widgets. Use blocking `Alert` only when appropriate for concise preflight intervention. + +## 6. Domain-specific rules and critical baselines + +**Hyperdrive:** Read its developer context before changing ingestion, JSON schema mapping, embedding request management, topic discovery, REST response models or landmark classification. Preserve managed request batching, stable input/response identity, typed Jackson POJOs, original N-dimensional embedding computations, and event-decoupled floating overlays. Use `CommandTerminalEvent` for status/warnings and SLF4J for detailed diagnostics. + +**Context Graph 3D:** Read its developer context before changing geometry, layouts, camera, styling or interactions. The verified edge optimization uses shared unit-cylinder `TriangleMesh` geometry with independent `MeshView` transforms and fixed progressive attachment, default **100 edges per pulse**. The measured 1,000-node/6,024-edge graph completed progressive edge loading in **1.162 seconds**. Preserve the corrected local scale-before-rotate ordering and the verified orbital camera/Fit Graph controls. Shared mesh geometry must not accidentally force shared per-object color state. The next approved program is a dedicated `ContextGraphNode3D`, benchmark, per-object coloration, then possible Projections-specific optimization; do **not** globally replace `AnimatedSphere`. + +**Projections and other 3D views:** Audit existing `Sphere`-specific API, label-color updates, selection, texture and event behavior before introducing `MeshView` replacements. Do not assume scaling relaxations for solid-color Context Graph edges apply to textured objects. + +## 7. Verification and delivery + +Before implementation, identify affected classes, event contracts, threading boundaries and regression risks. Add focused tests for pure logic; for JavaFX changes, verify runtime behavior on representative datasets and check camera responsiveness, picking, colors/opacity, scale/layout updates and cancellation. Run the project build where possible and report build and runtime verification separately. + +For performance work, retain a reproducible dataset, measure preparation time, FX attachment time, pulse gaps and end-to-end completion. Compare against the last verified baseline; do not infer native GPU allocation details solely from FX callback intervals. + +Deliver complete files or source-only deltas as requested. Name all replacements/deletions, avoid unrequested unrelated files, and state clearly when code has not been compiled or runtime-tested. When a change is accepted, update the relevant context guide and roadmap so the next developer inherits the current state. + +## 8. Quick-start workflow for incoming agents + +1. Read `README.md`, this `AGENTS.md`, and the relevant subsystem context document. +2. Inspect the actual current source and build configuration; identify stale documentation or conflicts. +3. Summarize the affected architecture, accepted behavior and proposed minimal change. +4. Confirm requirements and compatibility boundaries before implementation. +5. Implement in the correct worker/FX-thread layers, retaining generation and cancellation protections. +6. Build/test to the extent possible; clearly distinguish static checks from user-verified runtime behavior. +7. Update the appropriate subsystem document and roadmap when decisions or verified functionality change. + +**Principle:** Optimize and extend Trinity without erasing working behavior, data provenance, or the measured gains already achieved. diff --git a/CONTEXT_GRAPH_3D_DEVELOPER_CONTEXT.md b/CONTEXT_GRAPH_3D_DEVELOPER_CONTEXT.md new file mode 100644 index 00000000..b4e9682e --- /dev/null +++ b/CONTEXT_GRAPH_3D_DEVELOPER_CONTEXT.md @@ -0,0 +1,158 @@ +# Context Graph 3D — Developer Context and Implementation Guide + +**Project:** Trinity XAI — Java / JavaFX (current subsystem guidance targets Java 25; confirm toolchain in checkout) +**Status:** Living architectural and implementation context, updated 2026-11-06 +**Audience:** Human contributors and coding agents +**Scope:** Dedicated Context Graph 3D viewer, graph model, layouts, rendering, controls, camera, interactions, performance and planned improvements. + +> This document records accepted behavior and measured results, not a claim that every source snapshot is current. Always inspect the latest checkout and preserve user-verified changes. Proposed items are explicitly labeled. + +## 1. Mission and scope + +Context Graph 3D is Trinity's dedicated interactive 3D graph projection for exploring relationships and multiscale contextual hierarchies, including semantic connections between document chunks. It is a distinct `SubScene` projection rather than a feature embedded in Hypersurface. Graph structure is represented by `GraphDirectedCollection`, `GraphNode` and `GraphEdge`; visualization and interaction must not become the authoritative graph data model. + +Longer-term use cases include hierarchical document/page/paragraph/sentence relationships, semantic links, community structures, and layouts that help analysts discover structure without knowing keywords in advance. These are goals, not all verified end-user features. + +## 2. System architecture and important classes + +Paths are relative to `src/main/java/edu/jhuapl/trinity/`; verify locations in the current checkout. + +| Class / area | Responsibility | +|---|---| +| `javafx/javafx3d/contextgraph/ContextGraph3DPane.java` | SubScene, camera hierarchy, mouse/keyboard navigation, context menu, Fit Graph, pulse diagnostics | +| `javafx/javafx3d/contextgraph/ContextGraphController.java` | Rendering layers, graph loading, background preparation, progressive attachment, style/material state, layout and coordinate updates | +| `javafx/javafx3d/contextgraph/ContextGraphEdge3D.java` | Dedicated edge `MeshView`, shared unit-cylinder mesh, per-edge orientation/position/size | +| `javafx/javafx3d/contextgraph/ContextGraphInteractionController.java` | Node/edge interaction event routing and graph-related analytical signals | +| `javafx/components/panes/ContextGraphControlsPane.java` | Floating `LitPathPane` with Graph Layout, Graph Style and Display tabs | +| `javafx/components/GraphStyleControlsView.java` | Node/edge global color, radius/width and opacity controls; `GraphEvent` synchronization | +| `javafx/components/GraphControlsView.java` | Layout algorithm and parameter controls | +| `data/graph/GraphDirectedCollection.java`, `GraphNode.java`, `GraphEdge.java` | Graph model and stable node/edge identities | +| `data/graph/GraphFeatureCollectionAdapter.java` | Graph/FeatureCollection integration | +| `utils/graph/GraphLayoutParams.java`, `GraphStyleParams.java` | Layout/style parameters | +| `utils/graph/ForceFrLayout3D.java`, `GraphNetworkLayouts3D.java`, `GraphTopologyLayouts3D.java` | Layout computations | +| `javafx/events/GraphEvent.java`, `CommandTerminalEvent.java` | Graph control and notification events | +| `javafx/javafx3d/animated/AnimatedSphere.java` | Existing general-purpose interactive `Sphere` used by Context Graph nodes and other projections | +| `javafx/javafx3d/Projections3DPane.java` | Separate projection with heavy `AnimatedSphere` use; compatibility-sensitive | + +`ContextGraphController` owns `graphLayer`, `nodeLayer`, `edgeLayer`, typed `nodeViews` and `edgeViews` registries, `layoutPositions`, `coordinateScale`, `styleParams`, and render/layout generation counters. Never use `Node.userData` for model-to-view mapping. A `ContextGraphNode3D` class is **planned**, not part of the last verified baseline. + +## 3. Loading and rendering pipeline + +1. Import/deserialize `GraphDirectedCollection` away from the FX thread where possible. +2. `loadGraph()` establishes current graph and starts `rebuildGraph()`. +3. A background JavaFX `Task` prepares detached node and edge objects and computes positions. Constructing `TriangleMesh` off the FX thread is accepted in this codebase. +4. Attach nodes to the scene graph on the FX thread. The established `Platform.runLater()` pattern is intentional; do not replace it based on an unmeasured assumption that many callbacks are inherently problematic. +5. Prepare all edges off-thread and attach them progressively using an FX-thread `AnimationTimer`, reading a configurable batch size on each callback. **Verified default: 100 edges per pulse.** +6. Use `renderGeneration` checks so stale loads cannot attach geometry after a replacement load. Keep layout-generation checks for stale layout results. +7. Preserve progress-indicator animation and camera responsiveness; never move expensive geometry preparation or layout computation back onto the FX thread. + +### 3.1 Shared edge mesh — accepted optimization + +`ContextGraphEdge3D` extends `MeshView`, not `Cylinder`. Edge instances share a cached unit-cylinder `TriangleMesh` (potentially keyed by radial divisions), but retain separate `MeshView` instances, transforms and materials. The unit cylinder is Y-aligned. Per-edge dimensions use local `Scale` (X/Z radius, Y length), a `Rotate` to align Y to the endpoint direction, and midpoint translation. **Transform ordering is essential:** scale in local cylinder coordinates before rotation; the wrong order previously produced vertical, parallel, distorted edges. The corrected order is user-verified. + +The earlier restriction against Scale transforms was explicitly relaxed for solid-color Context Graph geometry, where texture distortion is not a concern. Do not assume the relaxation applies to textured meshes elsewhere. + +**Mesh sharing and material sharing are separate choices.** Reusing one mesh does not require reusing one mutable material. Dynamic per-object coloration needs careful material ownership or a palette cache. + +### 3.2 Node rendering — current and planned + +Current Context Graph nodes use `AnimatedSphere extends Sphere`, usually with hover animation disabled during graph construction. `AnimatedSphere` also supports selection, radius changes, hover `ScaleTransition`, and texture drag/drop. It is used extensively in `Projections3DPane`, where individual colors and labels can change at runtime. + +**Next agreed development plan:** introduce a dedicated `ContextGraphNode3D extends MeshView` using a shared unit-sphere `TriangleMesh`; benchmark it before changing `AnimatedSphere` or `Projections3DPane`. Preserve per-node size, position, color, opacity, hover and selection semantics. Context Graph hover enlargement should not exceed 10%. Do not assume shared-sphere optimization yields a large gain: the original 1,000 spheres already loaded quickly. + +## 4. Performance history and verified baseline + +Reference dataset: **1,000 nodes and 6,024 edges** (~766,701-character graph JSON). Times below are measured on the user's test system; do not generalize to other machines or graph sizes. + +| Experiment | Observed outcome | +|---|---| +| 1,000 `AnimatedSphere` only | ~1 s | +| ~6,000 JavaFX `Cylinder` only | ~10 s, UI freezes | +| Single batch of cylinders | ~12.1 s total, ~11.069 s FX pulse gap; geometry preparation ~75 ms, attachment ~61 ms | +| 50 cylinders/pulse | ~13.223 s, UI responsive but camera sluggish, max pulse gap ~145 ms | +| Initial 12 ms adaptive estimator | ~10% edges in 10 s; rejected as infeasible | +| AIMD adaptive with built-in cylinders | ~21.733 s, usually minimum 10 edges/pulse; rejected | +| Shared unit-cylinder mesh, 50/pulse | **2.329 s** progressive completion; ~2.504 s to all callbacks | +| Shared unit-cylinder mesh, **100/pulse** | **1.162 s** progressive completion; **1.376 s** to all callbacks; 61 batches | + +The last 100/pulse run reported background geometry preparation 83.992 ms, edge preparation 66.843 ms, maximum batch attachment 1.788 ms, progressive-loader maximum pulse gap 32.648 ms, and separate pane-monitor maximum gap 92.191 ms. The two pulse monitors cover different windows; do not conflate them. This is the **last measured performance baseline**, not a guarantee for future changes. + +**Conclusion from controlled tests:** the decisive optimization was shared `TriangleMesh` geometry. Batch scheduling alone did not remove the cylinder initialization cost. Preserve the shared-mesh edge architecture and the fixed 100-edge progressive loader unless new measurements justify changes. + +## 5. Camera and Fit Graph — verified behavior + +Camera uses a `CameraTransformer` parent for orbital pivot and yaw/pitch/roll, with `PerspectiveCamera` local translations. Negative camera-local Z represents orbital distance. Zoom changes camera-local Z, not the world pivot; this must work from behind the graph as well as in front. Pattern was reconciled with `Projections3DPane`. + +Controls: mouse drag rotates; mouse wheel zooms; Shift accelerates wheel zoom; on platforms that remap Shift-wheel to horizontal scrolling, use X delta when Y delta is zero; Ctrl offers fine zoom. W/S moves along local Z, A/D strafes, Space/X moves vertically; Shift accelerates movement. Preserve the existing orbital feel. + +Fit Graph computes the **displayed** graph bounds, including layout positions and coordinate scale, and accounts for camera orientation, SubScene dimensions/aspect ratio, field of view and desired viewport occupancy. It recenters the orbital pivot, resets camera-local X/Y offsets as appropriate, and calculates viewing distance. The editable **Fit Graph Percentage** spinner defaults to **85%** (10–100%, step 1%); it applies to the next Fit Graph operation, not automatically on spinner changes. The user verified these updates. + +## 6. Controls, styling and state + +`ContextGraphControlsPane` is a floating `LitPathPane`, not a modal dialog or embedded control inside the SubScene. It exposes Graph Layout, Graph Style and Display tabs. The right-click context menu opens the controls and Content Navigator and provides Fit Graph. + +- Display: show/hide nodes and edges; editable progressive edge batch spinner (default 100, range 1–1,000, increment 10); editable Coordinate Scale spinner (0.1–100.0, step 0.1, default 1.0); editable Fit Graph Percentage spinner (10–100%, step 1%, default 85%). +- Graph Style: global node color/radius/opacity and edge color/width/opacity. Edge Width spinner minimum 0.25, increment 0.25. `GraphStyleControlsView` emits `GRAPH_STYLE_PARAMS_CHANGED`; controller receives and applies changes. +- Graph Layout: layout kind and algorithm-specific parameters; expensive computation belongs on a worker, with FX-thread result application and generation protection. + +**Important historical bug:** an earlier diagnostic `TEST_EDGE_MATERIAL` was applied to every edge during loading and style updates, preventing Edge Color and Edge Opacity controls from working. A correction was supplied, but per-edge coloration and opacity through all lifecycle paths are **not yet comprehensively verified**. Inspect the current controller for remaining diagnostic overrides before implementing new color features. + +Opacity may use cached 1×1 self-illumination textures rather than ordinary diffuse alpha. Verify actual JavaFX rendering behavior rather than assuming both are equivalent. Changing a shared mutable `PhongMaterial` affects all views that reference it; use explicit per-object materials or well-defined shared palette ownership. + +## 7. Layout and graph data semantics + +The controller supports force-directed and network/hierarchy layout families, including FR, ForceAtlas, Kamada–Kawai, stress majorization, spectral, radial tree, layered hierarchy and hyperbolic hierarchy options. Validate exact availability and implementation in the current checkout. Some methods create topology/weight structures and compute coordinates asynchronously. Layout results are stored separately from the original node vectors in `layoutPositions`; the displayed coordinates apply `coordinateScale`. + +`refreshEdges()` and `rebuildGraph()` historically contain distinct edge-construction paths with different radial divisions. Consolidation is a **proposed cleanup**, not a prerequisite for the next node optimization. `updateEdgeEndpoints()` historically performs repeated graph node-ID lookup; profile before replacing. Layout/coordinate changes may update thousands of edge transforms on the FX thread and should be benchmarked at larger sizes. + +Graph-to-FeatureCollection adaptation should preserve stable node identity, source metadata and linked text/image content where supported. Do not store graph domain state in JavaFX nodes. + +## 8. Interaction status and remaining recovery + +Node/edge mouse transparency and geometry picking were disabled during performance recovery. `ContextGraphInteractionController` includes handlers for node/edge hover and click events, but do not infer that geometry dispatch is currently active. Remaining work includes re-enabling picking incrementally, node/edge hover, selection, Content Navigator updates, click inspection, and selection feedback, while benchmarking frame rate and initialization impact. Selection/hover state must preserve original per-object styling. + +Do not use `Node.userData` for picking. Use explicit typed registries and event handlers. Avoid rebuilding all geometry merely to change selection or color. + +## 9. Agreed next four-phase development program + +**Phase 1 — ContextGraphNode3D:** Design and implement a dedicated shared-unit-sphere `MeshView` class; keep `AnimatedSphere` unchanged. Integrate with `ContextGraphController` without changing edge rendering, camera, controls or layout behavior. Maintain individual size and interaction state. Avoid shared-mesh mutation after creation. + +**Phase 2 — Benchmark and regression:** Compare old and new node-only and full-graph loads; test 1,000 and 10,000+ nodes when available; measure preparation, FX attachment, pulse gaps, time to responsiveness, and camera FPS. Confirm `Projections3DPane` remains unaffected. Verify node sizes, coordinate scaling, layout updates, reloads, cancellation and Fit Graph. + +**Phase 3 — Per-object coloration:** Support independent node colors by label/category and edge colors by label/type, including runtime recoloring, global-vs-local precedence, opacity, selection restore, progressive attachment and layout changes. Test that recoloring one object never unintentionally recolors another. Compare per-object material allocation against cached palette materials with profiling. Do not assume label metadata fields without inspecting models. + +**Phase 4 — Projections3DPane evaluation:** Inventory its `AnimatedSphere` and `Sphere` API usage, label/gradient coloring, material mutation, hover, selection, drag/drop and texture features. A `MeshView` is not drop-in compatible with `Sphere` typing or `setRadius()`. Consider a dedicated `ProjectionsNode3D` and explicit migration only after independent functional and performance verification and user approval. Do not globally replace `AnimatedSphere` as a shortcut. + +## 10. Additional backlog (not yet verified complete) + +- Restore picking/hover/click/Content Navigator interaction and meaningful edge inspection. +- Verify style persistence through progressive loading, graph reloads, layout changes and resets. +- Test larger graph workloads (e.g., 5k/20k, 10k/50k nodes/edges) and avoid arbitrary hard limits. +- Review `refreshEdges()` reconstruction, material cache growth and coordinate update costs. +- Validate hyperbolic 3D hierarchy behavior and navigation, layout cancellation, and optional transitions. +- Explore graph community detection, centrality, semantic relationships and multiscale document hierarchy browsing as separate product initiatives. +- For any MDS-based graph layout, reuse existing Trinity SuperMDS/metrics infrastructure rather than duplicating distance algorithms. + +## 11. Contribution rules and threading + +- **Use the latest authoritative source checkout.** Prior generated variants may differ, and manually verified fixes take precedence over old patches. +- No wildcard imports; avoid inner classes for reusable models/controllers; avoid controller `Consumer` patterns; prefer explicit types. +- No `Node.userData` for model relationships. Maintain typed registries/maps. +- Keep computational work and detached mesh preparation off the FX thread; scene attachment, controls and scene-graph mutations on the FX thread. A `Task` need not originate on the FX thread. Preserve progress animation and generation/cancellation checks. +- Use the existing `GraphEvent`, `ApplicationEvent` and `CommandTerminalEvent` conventions. Use `LitPathPane` for substantial floating overlays, not ad hoc modal dialogs. Avoid inline CSS. +- Do not introduce new dependencies or architectural rewrites without evidence and approval. Preserve working shared-cylinder mesh, progressive loader and camera behavior. +- Instrument using `System.nanoTime()` and existing `Utils.printTotalTime()` conventions; distinguish background preparation, FX attachment, pulse gaps and user-visible responsiveness. Pulse callback intervals are not direct GPU presentation timestamps. +- Report what was actually built/tested; never equate source inspection with an integrated runtime test. + +## 12. Agent checklist before changes + +1. Read root `AGENTS.md`, this document and the latest source files. +2. Identify whether the change affects graph model, geometry, rendering, layout, camera, controls, or interactions. +3. Confirm current APIs and data ownership, especially `AnimatedSphere` compatibility and per-object material behavior. +4. Specify the smallest change that preserves the verified 100-edge shared-mesh baseline. +5. Plan FX-thread boundaries, cancellation/generation checks and timing measurements. +6. Implement only the approved phase; do not silently modify `Projections3DPane` during Context Graph node work. +7. Test rendering, styles, scaling, layout, camera, progress indicator, and graph reloads. +8. Update this document when accepted architecture or measured baseline changes; put speculative initiatives in a roadmap rather than claiming they are implemented. + +**Source of truth:** current code and user runtime verification override older handoffs and historical generated files. diff --git a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java index bdad0f80..c308188d 100644 --- a/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java +++ b/src/main/java/edu/jhuapl/trinity/AppAsyncManager.java @@ -65,11 +65,12 @@ import edu.jhuapl.trinity.javafx.handlers.SearchEventHandler; 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.hyperspace.Hyperspace3DPane; import edu.jhuapl.trinity.javafx.javafx3d.hypersurface.Hypersurface3DPane; +import edu.jhuapl.trinity.javafx.javafx3d.contextgraph.ContextGraph3DPane; import edu.jhuapl.trinity.javafx.javafx3d.Manifold3D; -import edu.jhuapl.trinity.javafx.javafx3d.Projections3DPane; -import edu.jhuapl.trinity.javafx.javafx3d.ProjectorPane; +import edu.jhuapl.trinity.javafx.javafx3d.projections.Projections3DPane; +import edu.jhuapl.trinity.javafx.javafx3d.projections.ProjectorPane; import edu.jhuapl.trinity.javafx.javafx3d.RetroWavePane; import edu.jhuapl.trinity.javafx.services.FeatureVectorManagerService; import edu.jhuapl.trinity.javafx.services.FeatureVectorManagerServiceImpl; @@ -127,6 +128,7 @@ public class AppAsyncManager extends Task { Hyperspace3DPane hyperspace3DPane; Hypersurface3DPane hypersurface3DPane; + ContextGraph3DPane contextGraph3DPane; HypersurfaceControlsPane hypersurfaceControlsPane; ProjectorPane projectorPane; Projections3DPane projections3DPane; @@ -340,6 +342,11 @@ protected Void call() throws Exception { hypersurface3DPane.setVisible(false); //start off hidden Platform.runLater(() -> centerStack.getChildren().add(0, hypersurface3DPane)); + progress.setLabelLater("...Context Graph..."); + contextGraph3DPane = new ContextGraph3DPane(scene); + contextGraph3DPane.setVisible(false); + Platform.runLater(() -> centerStack.getChildren().add(0, contextGraph3DPane)); + progress.setLabelLater("...Hyperspace..."); progress.setPercentComplete(current++ / total); hyperspace3DPane = new Hyperspace3DPane(scene); @@ -559,6 +566,7 @@ protected Void call() throws Exception { } hyperspace3DPane.setVisible(false); hypersurface3DPane.setVisible(false); + contextGraph3DPane.setVisible(false); Platform.runLater(() -> projections3DPane.setVisible(true)); } }); @@ -711,6 +719,19 @@ protected Void call() throws Exception { projections3DPane.enableAutoProjection(enabled); hyperspace3DPane.enableAutoProjection(enabled); }); + LOG.info("Context Graph "); + scene.addEventHandler(ApplicationEvent.SHOW_CONTEXT_GRAPH, e -> { + if (contextGraph3DPane.isVisible()) { + contextGraph3DPane.setVisible(false); + if (retroWavePane != null) retroWavePane.animateShow(); + } else { + if (retroWavePane != null) retroWavePane.animateHide(); + hypersurface3DPane.setVisible(false); + hyperspace3DPane.setVisible(false); + projections3DPane.setVisible(false); + contextGraph3DPane.setVisible(true); + } + }); LOG.info("Hypersurface "); scene.addEventHandler(ApplicationEvent.SHOW_HYPERSURFACE, e -> { if (hypersurface3DPane.isVisible()) { @@ -723,6 +744,7 @@ protected Void call() throws Exception { retroWavePane.animateHide(); } hyperspace3DPane.setVisible(false); + contextGraph3DPane.setVisible(false); if (null != projections3DPane) { projections3DPane.setVisible(false); } @@ -744,6 +766,7 @@ protected Void call() throws Exception { } } else { hypersurface3DPane.setVisible(false); + contextGraph3DPane.setVisible(false); if (null != projections3DPane) { projections3DPane.setVisible(false); } @@ -1140,12 +1163,17 @@ else if (null != projections3DPane.latestUmap) centerStack.addEventHandler(DragEvent.DRAG_DROPPED, e -> { String dropType = ResourceUtils.detectDropType(e); if (!dropType.contentEquals("UNKNOWN")) { - if (ResourceUtils.promptUserOnCommand(dropType)) { + if ("Context Graph".equals(dropType) && contextGraph3DPane.isVisible() + || ResourceUtils.promptUserOnCommand(dropType)) { try { switch (dropType) { case "Hyperspace" -> CommandTask.execute(scene, "VIEW_HYPERSPACE", 0, null); case "Hypersurface" -> CommandTask.execute(scene, "VIEW_HYPERSURFACE", 0, null); case "Projections" -> CommandTask.execute(scene, "VIEW_PROJECTIONS", 0, null); + case "Context Graph" -> { + if (!contextGraph3DPane.isVisible()) + scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.SHOW_CONTEXT_GRAPH)); + } } } catch (InterruptedException ex) { LOG.error(null, ex); diff --git a/src/main/java/edu/jhuapl/trinity/data/files/GraphDirectedCollectionFile.java b/src/main/java/edu/jhuapl/trinity/data/files/GraphDirectedCollectionFile.java index 18dc4e57..beb03197 100644 --- a/src/main/java/edu/jhuapl/trinity/data/files/GraphDirectedCollectionFile.java +++ b/src/main/java/edu/jhuapl/trinity/data/files/GraphDirectedCollectionFile.java @@ -77,8 +77,12 @@ private GraphDirectedCollection parseContent() throws IOException { /** Provides deserialization support for JSON messages */ ObjectMapper mapper = new ObjectMapper(); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + long readStart = System.nanoTime(); String message = Files.readString(this.toPath()); + System.out.printf("[CG-TIMING] file read %.3f ms chars=%d%n", (System.nanoTime()-readStart)/1e6, message.length()); + long jsonStart = System.nanoTime(); graphDirectedCollection = mapper.readValue(message, GraphDirectedCollection.class); + System.out.printf("[CG-TIMING] JSON deserialize %.3f ms%n", (System.nanoTime()-jsonStart)/1e6); return graphDirectedCollection; } diff --git a/src/main/java/edu/jhuapl/trinity/data/graph/GraphFeatureCollectionAdapter.java b/src/main/java/edu/jhuapl/trinity/data/graph/GraphFeatureCollectionAdapter.java new file mode 100644 index 00000000..a44c1f64 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/data/graph/GraphFeatureCollectionAdapter.java @@ -0,0 +1,63 @@ +package edu.jhuapl.trinity.data.graph; + +import edu.jhuapl.trinity.data.messages.xai.FeatureCollection; +import edu.jhuapl.trinity.data.messages.xai.FeatureVector; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Collections; + +/** Maintains original graph objects alongside FeatureVectors for shared Trinity tools. */ +public final class GraphFeatureCollectionAdapter { + private final FeatureCollection featureCollection = new FeatureCollection(); + private final Map nodesById = new LinkedHashMap<>(); + private final Map featuresById = new LinkedHashMap<>(); + private final Map nodesByFeature = new IdentityHashMap<>(); + + public GraphFeatureCollectionAdapter(GraphDirectedCollection graph) { + if (graph == null || graph.getNodes() == null) return; + for (GraphNode node : graph.getNodes()) { + if (node == null || node.getEntityID() == null) continue; + String id = node.getEntityID(); + if (nodesById.containsKey(id)) { + throw new IllegalArgumentException("Duplicate graph node ID: " + id); + } + FeatureVector feature = new FeatureVector(node.getVector()); + feature.setEntityId(id); + feature.setLabel(node.getLabels() == null || node.getLabels().isEmpty() + ? id : String.join(", ", node.getLabels())); + feature.getMetaData().put("graph.nodeId", id); + if (node.getProperties() != null) { + node.getProperties().forEach((key, value) -> { + if (key != null && value != null) + feature.getMetaData().put(key, String.valueOf(value)); + }); + Object text = node.getProperties().get("text"); + if (text != null) feature.setText(String.valueOf(text)); + } + if (feature.getText() == null || feature.getText().isBlank()) { + StringBuilder summary = new StringBuilder("Graph Node: ").append(id); + if (node.getLabels() != null && !node.getLabels().isEmpty()) + summary.append("\nLabels: ").append(String.join(", ", node.getLabels())); + if (node.getProperties() != null) node.getProperties().forEach((key,value) -> { + if (key != null && value != null) summary.append("\n").append(key).append(": ").append(value); + }); + feature.setText(summary.toString()); + } + nodesById.put(id, node); + featuresById.put(id, feature); + nodesByFeature.put(feature, node); + featureCollection.getFeatures().add(feature); + } + } + + public FeatureCollection getFeatureCollection() { return featureCollection; } + public FeatureVector featureFor(GraphNode node) { + return node == null ? null : featuresById.get(node.getEntityID()); + } + public GraphNode graphNodeFor(FeatureVector feature) { return nodesByFeature.get(feature); } + public GraphNode graphNodeFor(String id) { return nodesById.get(id); } + public Map getFeaturesById() { + return Collections.unmodifiableMap(featuresById); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/GraphControlsView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/GraphControlsView.java index 41418f0c..6a108d1c 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/GraphControlsView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/GraphControlsView.java @@ -40,6 +40,10 @@ public final class GraphControlsView extends VBox { // Layout private ComboBox layoutKindCombo; private Spinner radiusSpinner; + private VBox algorithmControls; + private boolean syncing; + private Spinner landmarkSpinner; + private Spinner seedSpinner; // Edge policy private ComboBox edgePolicyCombo; @@ -67,11 +71,12 @@ public final class GraphControlsView extends VBox { public GraphControlsView(Scene scene) { this.scene = scene; - this.params = new GraphLayoutParams(); // your defaults + this.params = new GraphLayoutParams(); // graph-only defaults + this.params.radius = 30.0; setSpacing(10); setPadding(new Insets(6)); getChildren().add(buildContent()); - fireParamsChanged(); // publish defaults once + // Do not publish default layout settings merely by opening controls. } public GraphLayoutParams getParamsCopy() { @@ -82,198 +87,118 @@ public void setParams(GraphLayoutParams p) { if (p == null) return; copyInto(p, params); syncControlsFromParams(); - fireParamsChanged(); + // External synchronization must not trigger a layout recalculation. } // UI ----------------------------------------------------------------- private Pane buildContent() { - return new VBox(10, + algorithmControls = new VBox(8); + VBox root = new VBox(10, titledBox("Layout", buildLayoutGrid()), - titledBox("Edges / Sparsification", buildEdgesGrid()), - titledBox("Force-Directed (FR)", buildForceGrid()), + algorithmControls, buildActionsBar() ); + updateAlgorithmControls(); + return root; } private GridPane buildLayoutGrid() { GridPane gp = formGrid(); - layoutKindCombo = new ComboBox<>(); - layoutKindCombo.getItems().addAll(LayoutKind.values()); + for (LayoutKind kind : LayoutKind.values()) { + // MDS belongs in Projections; matrix sparsification is not a layout control. + if (kind != LayoutKind.MDS_3D) layoutKindCombo.getItems().add(kind); + } layoutKindCombo.getSelectionModel().select(params.kind); styleCombo(layoutKindCombo); - addRow(gp, 0, "Kind", layoutKindCombo); - - radiusSpinner = new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory(10.0, 100_000.0, params.radius, 10.0)); - radiusSpinner.setEditable(true); - styleSpinner(radiusSpinner); - addRow(gp, 1, "Radius", radiusSpinner); - + addRow(gp, 0, "Algorithm", layoutKindCombo); layoutKindCombo.setOnAction(e -> { + if (syncing || layoutKindCombo.getValue() == null) return; params.kind = layoutKindCombo.getValue(); - toggleForceSectionVisibility(); + updateAlgorithmControls(); fireParamsChanged(); }); - radiusSpinner.valueProperty().addListener((o, ov, nv) -> { - params.radius = nv; - fireParamsChanged(); - }); - return gp; } - private GridPane buildEdgesGrid() { + /** Only expose settings that are consumed by the selected implementation. */ + private void updateAlgorithmControls() { + if (algorithmControls == null || layoutKindCombo == null) return; + algorithmControls.getChildren().clear(); + LayoutKind kind = layoutKindCombo.getValue(); + if (kind == null) return; GridPane gp = formGrid(); - - edgePolicyCombo = new ComboBox<>(); - edgePolicyCombo.getItems().addAll(EdgePolicy.values()); - edgePolicyCombo.getSelectionModel().select(params.edgePolicy); - styleCombo(edgePolicyCombo); - addRow(gp, 0, "Policy", edgePolicyCombo); - - kSpinner = new Spinner<>(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 10_000, params.knnK, 1)); - kSpinner.setEditable(true); - styleSpinner(kSpinner); - addRow(gp, 1, "k (KNN/MST+K)", kSpinner); - - knnSymmetrizeCheck = new CheckBox("Symmetrize KNN"); - knnSymmetrizeCheck.setSelected(params.knnSymmetrize); - styleCheck(knnSymmetrizeCheck); - addRow(gp, 2, "", knnSymmetrizeCheck); - - epsSpinner = new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 1e9, params.epsilon, 0.1)); - epsSpinner.setEditable(true); - styleSpinner(epsSpinner); - addRow(gp, 3, "ε (Epsilon)", epsSpinner); - - buildMstCheck = new CheckBox("Build MST (for MST+K)"); - buildMstCheck.setSelected(params.buildMst); - styleCheck(buildMstCheck); - addRow(gp, 4, "", buildMstCheck); - - maxEdgesSpinner = new Spinner<>(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, 2_000_000, params.maxEdges, 100)); - maxEdgesSpinner.setEditable(true); - styleSpinner(maxEdgesSpinner); - addRow(gp, 5, "Max edges", maxEdgesSpinner); - - maxDegreeSpinner = new Spinner<>(new SpinnerValueFactory.IntegerSpinnerValueFactory(0, 10_000, params.maxDegreePerNode, 1)); - maxDegreeSpinner.setEditable(true); - styleSpinner(maxDegreeSpinner); - addRow(gp, 6, "Max degree/node", maxDegreeSpinner); - - minWeightSpinner = new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 1e9, params.minEdgeWeight, 0.01)); - minWeightSpinner.setEditable(true); - styleSpinner(minWeightSpinner); - addRow(gp, 7, "Min edge weight", minWeightSpinner); - - norm01Check = new CheckBox("Normalize weights [0,1]"); - norm01Check.setSelected(params.normalizeWeights01); - styleCheck(norm01Check); - addRow(gp, 8, "", norm01Check); - - edgePolicyCombo.setOnAction(e -> { - params.edgePolicy = edgePolicyCombo.getValue(); - toggleEdgePolicyFields(); - fireParamsChanged(); - }); - kSpinner.valueProperty().addListener((o, ov, nv) -> { - params.knnK = nv; - fireParamsChanged(); - }); - knnSymmetrizeCheck.setOnAction(e -> { - params.knnSymmetrize = knnSymmetrizeCheck.isSelected(); - fireParamsChanged(); - }); - epsSpinner.valueProperty().addListener((o, ov, nv) -> { - params.epsilon = nv; - fireParamsChanged(); - }); - buildMstCheck.setOnAction(e -> { - params.buildMst = buildMstCheck.isSelected(); - fireParamsChanged(); - }); - maxEdgesSpinner.valueProperty().addListener((o, ov, nv) -> { - params.maxEdges = nv; - fireParamsChanged(); - }); - maxDegreeSpinner.valueProperty().addListener((o, ov, nv) -> { - params.maxDegreePerNode = nv; - fireParamsChanged(); - }); - minWeightSpinner.valueProperty().addListener((o, ov, nv) -> { - params.minEdgeWeight = nv; - fireParamsChanged(); - }); - norm01Check.setOnAction(e -> { - params.normalizeWeights01 = norm01Check.isSelected(); - fireParamsChanged(); + int row = 0; + radiusSpinner = new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory( + 1.0, 100_000.0, Math.max(1, params.radius), 10.0)); + radiusSpinner.setEditable(true); + styleSpinner(radiusSpinner); + addRow(gp, row++, "Layout extent", radiusSpinner); + radiusSpinner.valueProperty().addListener((o, ov, nv) -> { + if (!syncing && nv != null) { params.radius = nv; fireParamsChanged(); } }); - - toggleEdgePolicyFields(); - return gp; + boolean iterative = kind == LayoutKind.FORCE_FR || kind == LayoutKind.FORCE_ATLAS_3D + || kind == LayoutKind.KAMADA_KAWAI_3D || kind == LayoutKind.STRESS_MAJOR_3D + || kind == LayoutKind.SPECTRAL_3D; + if (iterative) { + itersSpinner = new Spinner<>(new SpinnerValueFactory.IntegerSpinnerValueFactory( + 1, 200_000, Math.max(1, params.iterations), 25)); + itersSpinner.setEditable(true); + styleSpinner(itersSpinner); + addRow(gp, row++, "Iterations", itersSpinner); + itersSpinner.valueProperty().addListener((o, ov, nv) -> { + if (!syncing && nv != null) { params.iterations = nv; fireParamsChanged(); } + }); + } + if (kind == LayoutKind.KAMADA_KAWAI_3D || kind == LayoutKind.STRESS_MAJOR_3D) { + landmarkSpinner = new Spinner<>(new SpinnerValueFactory.IntegerSpinnerValueFactory( + 1, 50_000, Math.max(1, params.landmarkCount), 4)); + landmarkSpinner.setEditable(true); + styleSpinner(landmarkSpinner); + addRow(gp, row++, "Graph landmarks", landmarkSpinner); + landmarkSpinner.valueProperty().addListener((o, ov, nv) -> { + if (!syncing && nv != null) { params.landmarkCount = nv; fireParamsChanged(); } + }); + } + if (kind == LayoutKind.FORCE_ATLAS_3D || kind == LayoutKind.KAMADA_KAWAI_3D + || kind == LayoutKind.STRESS_MAJOR_3D || kind == LayoutKind.SPECTRAL_3D) { + seedSpinner = new Spinner<>(new SpinnerValueFactory.IntegerSpinnerValueFactory( + 0, Integer.MAX_VALUE, (int)Math.max(0, Math.min(Integer.MAX_VALUE, params.randomSeed)), 1)); + seedSpinner.setEditable(true); + styleSpinner(seedSpinner); + addRow(gp, row++, "Random seed", seedSpinner); + seedSpinner.valueProperty().addListener((o, ov, nv) -> { + if (!syncing && nv != null) { params.randomSeed = nv; fireParamsChanged(); } + }); + } + if (kind == LayoutKind.FORCE_FR || kind == LayoutKind.FORCE_ATLAS_3D) { + stepSpinner = doubleSetting(gp, row++, "Step", params.step, .0001, 1000, .01, + v -> params.step = v); + repulseSpinner = doubleSetting(gp, row++, "Repulsion", params.repulsion, 0, 1e9, 1, + v -> params.repulsion = v); + attractSpinner = doubleSetting(gp, row++, "Attraction", params.attraction, 0, 1e6, .01, + v -> params.attraction = v); + gravitySpinner = doubleSetting(gp, row++, "Gravity", params.gravity, 0, 10, .01, + v -> params.gravity = v); + coolingSpinner = doubleSetting(gp, row++, "Cooling", params.cooling, .5, .9999, .001, + v -> params.cooling = v); + } + algorithmControls.getChildren().add(titledBox("Parameters — " + kind, gp)); } - private GridPane buildForceGrid() { - GridPane gp = formGrid(); - - itersSpinner = new Spinner<>(new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 200_000, params.iterations, 10)); - itersSpinner.setEditable(true); - styleSpinner(itersSpinner); - addRow(gp, 0, "Iterations", itersSpinner); - - stepSpinner = new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0001, 1000.0, params.step, 0.01)); - stepSpinner.setEditable(true); - styleSpinner(stepSpinner); - addRow(gp, 1, "Step", stepSpinner); - - repulseSpinner = new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 1e9, params.repulsion, 1.0)); - repulseSpinner.setEditable(true); - styleSpinner(repulseSpinner); - addRow(gp, 2, "Repulsion", repulseSpinner); - - attractSpinner = new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 1e6, params.attraction, 0.01)); - attractSpinner.setEditable(true); - styleSpinner(attractSpinner); - addRow(gp, 3, "Attraction", attractSpinner); - - gravitySpinner = new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 10.0, params.gravity, 0.01)); - gravitySpinner.setEditable(true); - styleSpinner(gravitySpinner); - addRow(gp, 4, "Gravity", gravitySpinner); - - coolingSpinner = new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.5, 0.9999, params.cooling, 0.001)); - coolingSpinner.setEditable(true); - styleSpinner(coolingSpinner); - addRow(gp, 5, "Cooling", coolingSpinner); - - itersSpinner.valueProperty().addListener((o, ov, nv) -> { - params.iterations = nv; - fireParamsChanged(); - }); - stepSpinner.valueProperty().addListener((o, ov, nv) -> { - params.step = nv; - fireParamsChanged(); + private Spinner doubleSetting(GridPane gp, int row, String label, double initial, + double min, double max, double increment, + java.util.function.DoubleConsumer setter) { + Spinner spinner = new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory( + min, max, Math.max(min, Math.min(max, initial)), increment)); + spinner.setEditable(true); + styleSpinner(spinner); + addRow(gp, row, label, spinner); + spinner.valueProperty().addListener((o, ov, nv) -> { + if (!syncing && nv != null) { setter.accept(nv); fireParamsChanged(); } }); - repulseSpinner.valueProperty().addListener((o, ov, nv) -> { - params.repulsion = nv; - fireParamsChanged(); - }); - attractSpinner.valueProperty().addListener((o, ov, nv) -> { - params.attraction = nv; - fireParamsChanged(); - }); - gravitySpinner.valueProperty().addListener((o, ov, nv) -> { - params.gravity = nv; - fireParamsChanged(); - }); - coolingSpinner.valueProperty().addListener((o, ov, nv) -> { - params.cooling = nv; - fireParamsChanged(); - }); - - toggleForceSectionVisibility(); - return gp; + return spinner; } private HBox buildActionsBar() { @@ -297,51 +222,14 @@ private HBox buildActionsBar() { // Behavior ------------------------------------------------------------ - private void toggleEdgePolicyFields() { - EdgePolicy p = edgePolicyCombo.getValue(); - boolean useK = (p == EdgePolicy.KNN || p == EdgePolicy.MST_PLUS_K); - boolean useEps = (p == EdgePolicy.EPSILON); - - kSpinner.setDisable(!useK); - knnSymmetrizeCheck.setDisable(!(p == EdgePolicy.KNN)); // only meaningful for KNN - epsSpinner.setDisable(!useEps); - buildMstCheck.setDisable(p != EdgePolicy.MST_PLUS_K); - } - - private void toggleForceSectionVisibility() { - boolean fr = (layoutKindCombo.getValue() == LayoutKind.FORCE_FR); - itersSpinner.setDisable(!fr); - stepSpinner.setDisable(!fr); - repulseSpinner.setDisable(!fr); - attractSpinner.setDisable(!fr); - gravitySpinner.setDisable(!fr); - coolingSpinner.setDisable(!fr); - } - private void syncControlsFromParams() { - layoutKindCombo.getSelectionModel().select(params.kind); - radiusSpinner.getValueFactory().setValue(params.radius); - - edgePolicyCombo.getSelectionModel().select(params.edgePolicy); - kSpinner.getValueFactory().setValue(params.knnK); - knnSymmetrizeCheck.setSelected(params.knnSymmetrize); - epsSpinner.getValueFactory().setValue(params.epsilon); - buildMstCheck.setSelected(params.buildMst); - - maxEdgesSpinner.getValueFactory().setValue(params.maxEdges); - maxDegreeSpinner.getValueFactory().setValue(params.maxDegreePerNode); - minWeightSpinner.getValueFactory().setValue(params.minEdgeWeight); - norm01Check.setSelected(params.normalizeWeights01); - - itersSpinner.getValueFactory().setValue(params.iterations); - stepSpinner.getValueFactory().setValue(params.step); - repulseSpinner.getValueFactory().setValue(params.repulsion); - attractSpinner.getValueFactory().setValue(params.attraction); - gravitySpinner.getValueFactory().setValue(params.gravity); - coolingSpinner.getValueFactory().setValue(params.cooling); - - toggleEdgePolicyFields(); - toggleForceSectionVisibility(); + syncing = true; + try { + layoutKindCombo.getSelectionModel().select(params.kind); + } finally { + syncing = false; + } + updateAlgorithmControls(); } private void fireParamsChanged() { @@ -420,6 +308,8 @@ private static void copyInto(GraphLayoutParams src, GraphLayoutParams dst) { // force dst.iterations = src.iterations; + dst.landmarkCount = src.landmarkCount; + dst.randomSeed = src.randomSeed; dst.step = src.step; dst.repulsion = src.repulsion; dst.attraction = src.attraction; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/GraphStyleControlsView.java b/src/main/java/edu/jhuapl/trinity/javafx/components/GraphStyleControlsView.java index bc8d6329..bd2a5598 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/GraphStyleControlsView.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/GraphStyleControlsView.java @@ -77,8 +77,7 @@ public GraphStyleControlsView(Scene scene) { getChildren().add(buildContent()); wireGuiSyncHandlers(); - // publish defaults once - fireParamsChanged(); + // Controls are passive on construction; the graph model owns current state. } // --------------------------------------------------------------------- @@ -135,7 +134,7 @@ private GridPane buildEdgesGrid() { styleColorPicker(edgeColorPicker); addRow(gp, 0, "Color", edgeColorPicker); - edgeWidthSpinner = new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.5, 200.0, params.edgeWidth, 0.5)); + edgeWidthSpinner = new Spinner<>(new SpinnerValueFactory.DoubleSpinnerValueFactory(0.25, 200.0, Math.max(0.25, params.edgeWidth), 0.25)); edgeWidthSpinner.setEditable(true); styleSpinner(edgeWidthSpinner); addRow(gp, 1, "Width", edgeWidthSpinner); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/panes/ContextGraphControlsPane.java b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/ContextGraphControlsPane.java new file mode 100644 index 00000000..ec5e5f9d --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/panes/ContextGraphControlsPane.java @@ -0,0 +1,113 @@ +package edu.jhuapl.trinity.javafx.components.panes; + +import edu.jhuapl.trinity.javafx.components.GraphControlsView; +import edu.jhuapl.trinity.javafx.components.GraphStyleControlsView; +import edu.jhuapl.trinity.javafx.javafx3d.contextgraph.ContextGraphController; +import edu.jhuapl.trinity.javafx.javafx3d.contextgraph.ContextGraph3DPane; +import javafx.geometry.Insets; +import javafx.scene.Scene; +import javafx.scene.control.CheckBox; +import javafx.scene.control.Label; +import javafx.scene.control.Spinner; +import javafx.scene.control.SpinnerValueFactory; +import javafx.scene.control.Tab; +import javafx.scene.control.TabPane; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.Pane; +import javafx.scene.layout.VBox; +import javafx.util.StringConverter; + +import java.util.Locale; + +/** Dedicated floating controls for the Context Graph view. */ +public final class ContextGraphControlsPane extends LitPathPane { + public ContextGraphControlsPane(Scene scene, Pane parent, ContextGraphController graphController, + ContextGraph3DPane graphPane) { + super(scene, parent, 420, 620, new BorderPane(), "Context Graph Controls", "", 200.0, 300.0); + setPickOnBounds(false); + setFocusTraversable(false); + + Spinner coordinateScale = new Spinner<>(); + SpinnerValueFactory.DoubleSpinnerValueFactory coordinateValues = + new SpinnerValueFactory.DoubleSpinnerValueFactory(0.1, 100.0, graphController.getCoordinateScale(), 0.1); + coordinateValues.setConverter(new StringConverter<>() { + @Override public String toString(Double value) { + return value == null ? "" : String.format(Locale.ROOT, "%.1f", value); + } + @Override public Double fromString(String text) { + double value = Double.parseDouble(text.trim()); + if (!Double.isFinite(value) || value < 0.1 || value > 100.0) { + throw new IllegalArgumentException("Coordinate scale must be between 0.1 and 100.0"); + } + return value; + } + }); + coordinateScale.setValueFactory(coordinateValues); + coordinateScale.setEditable(true); + coordinateScale.setPrefWidth(130); + coordinateScale.valueProperty().addListener((obs, oldValue, newValue) -> { + if (newValue != null) { + graphController.setCoordinateScale(newValue); + graphController.finishCoordinateScale(); + } + }); + installCommitOnFocusLost(coordinateScale); + + VBox styleContent = new VBox(10, new Label("Coordinate Scale (positions only)"), + coordinateScale, new GraphStyleControlsView(scene)); + styleContent.setPadding(new Insets(6)); + + CheckBox showNodes = new CheckBox("Show Nodes"); + showNodes.setSelected(graphController.isNodesDisplayed()); + showNodes.setOnAction(e -> graphController.setNodesDisplayed(showNodes.isSelected())); + CheckBox showEdges = new CheckBox("Show Edges"); + showEdges.setSelected(graphController.isEdgesDisplayed()); + showEdges.setOnAction(e -> graphController.setEdgesDisplayed(showEdges.isSelected())); + + Spinner edgeBatchSize = new Spinner<>(1, 1000, graphController.getEdgeBatchSize(), 10); + edgeBatchSize.setEditable(true); + edgeBatchSize.setPrefWidth(130); + edgeBatchSize.valueProperty().addListener((obs, oldValue, newValue) -> { + if (newValue != null) graphController.setEdgeBatchSize(newValue); + }); + installCommitOnFocusLost(edgeBatchSize); + + Spinner fitPercentage = new Spinner<>( + new SpinnerValueFactory.DoubleSpinnerValueFactory(10.0, 100.0, + graphPane.getFitGraphPercentage(), 1.0)); + fitPercentage.setEditable(true); + fitPercentage.setPrefWidth(130); + fitPercentage.valueProperty().addListener((obs, oldValue, newValue) -> { + if (newValue != null) graphPane.setFitGraphPercentage(newValue); + }); + installCommitOnFocusLost(fitPercentage); + + VBox displayContent = new VBox(12, showNodes, showEdges, + new Label("Edges per rendering pulse"), edgeBatchSize, + new Label("Fit Graph Percentage"), fitPercentage); + displayContent.setPadding(new Insets(12)); + TabPane tabs = new TabPane( + new Tab("Graph Layout", new GraphControlsView(scene)), + new Tab("Graph Style", styleContent), + new Tab("Display", displayContent)); + tabs.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE); + ((BorderPane) contentPane).setCenter(tabs); + } + + /** Commit typed spinner values on Enter or focus loss; revert invalid entries. */ + private static void installCommitOnFocusLost(Spinner spinner) { + Runnable commit = () -> { + SpinnerValueFactory factory = spinner.getValueFactory(); + try { + T parsed = factory.getConverter().fromString(spinner.getEditor().getText()); + factory.setValue(parsed); + } catch (RuntimeException invalid) { + spinner.getEditor().setText(factory.getConverter().toString(factory.getValue())); + } + }; + spinner.getEditor().setOnAction(e -> commit.run()); + spinner.getEditor().focusedProperty().addListener((obs, wasFocused, focused) -> { + if (!focused) commit.run(); + }); + } +} 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 4b4c5913..900abe88 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 @@ -1,8 +1,5 @@ package edu.jhuapl.trinity.javafx.components.panes; -import edu.jhuapl.trinity.javafx.components.GraphControlsView; -import edu.jhuapl.trinity.javafx.components.GraphStyleControlsView; -import edu.jhuapl.trinity.javafx.events.GraphEvent; import edu.jhuapl.trinity.javafx.events.HyperspaceEvent; import edu.jhuapl.trinity.javafx.events.HypersurfaceEvent; import edu.jhuapl.trinity.javafx.javafx3d.hypersurface.Hypersurface3DPane; @@ -97,8 +94,6 @@ public class HypersurfaceControlsPane extends LitPathPane { private ColorPicker pointColorPicker; private CheckBox showLegendCheck; - // Graph overlay visibility - private ToggleButton showGraphToggle; // Tiled LOD controls private ComboBox tileSizeCombo; @@ -189,15 +184,6 @@ public HypersurfaceControlsPane(Scene scene, Pane parent, Hypersurface3DPane tar }); } - // Graph overlay visibility GUI sync - scene.addEventHandler(GraphEvent.SET_GRAPH_VISIBILITY_GUI, e -> { - if (showGraphToggle != null) { - boolean v = (Boolean) e.object; - if (showGraphToggle.isSelected() != v) showGraphToggle.setSelected(v); - } - e.consume(); - }); - startLodDiagnosticsTimer(); } @@ -418,13 +404,6 @@ private TabPane buildTabs() { }); // --- Graph Overlay visibility --- - GridPane graphOverlayGrid = formGrid(); - showGraphToggle = new ToggleButton("Show Graph"); - showGraphToggle.setSelected(true); // will be synced on startup via SET_GRAPH_VISIBILITY_GUI - showGraphToggle.setOnAction(e -> - fireOnRoot(new GraphEvent(GraphEvent.GRAPH_VISIBILITY_CHANGED, showGraphToggle.isSelected())) - ); - addRow(graphOverlayGrid, 0, "Graph Overlay", showGraphToggle); VBox viewTabContent = new VBox(10, titledBox("Dimensions", dimsGrid), @@ -684,21 +663,10 @@ private TabPane buildTabs() { tabs.setPrefHeight(TAB_CONTENT_HEIGHT); tabs.setMaxHeight(Double.MAX_VALUE); - GraphControlsView graphLayoutView = new GraphControlsView(scene); - GraphStyleControlsView graphStyleView = new GraphStyleControlsView(scene); - VBox graphStyleTabContent = new VBox(10, - titledBox("Graph Overlay", graphOverlayGrid), - graphStyleView); - graphStyleTabContent.setPadding(new Insets(6)); - VBox.setVgrow(graphStyleView, Priority.ALWAYS); - Tab t1 = new Tab("View", viewTabContent); Tab t2 = new Tab("Processing", procTabContent); Tab t3 = new Tab("LOD", lodScrollPane); - Tab t4 = new Tab("Graph Layout", graphLayoutView); - Tab t5 = new Tab("Graph Style", graphStyleTabContent); - - tabs.getTabs().addAll(t1, t2, t3, t4, t5); + tabs.getTabs().addAll(t1, t2, t3); return tabs; } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/radial/HyperspaceMenu.java b/src/main/java/edu/jhuapl/trinity/javafx/components/radial/HyperspaceMenu.java index 3ba0f6c8..d8a006d2 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/radial/HyperspaceMenu.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/radial/HyperspaceMenu.java @@ -5,7 +5,7 @@ import edu.jhuapl.trinity.javafx.components.panes.SearchPane; import edu.jhuapl.trinity.javafx.events.ApplicationEvent; import edu.jhuapl.trinity.javafx.events.FeatureVectorEvent; -import edu.jhuapl.trinity.javafx.javafx3d.Hyperspace3DPane; +import edu.jhuapl.trinity.javafx.javafx3d.hyperspace.Hyperspace3DPane; import edu.jhuapl.trinity.utils.ResourceUtils; import javafx.animation.KeyFrame; import javafx.animation.KeyValue; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/radial/MainNavMenu.java b/src/main/java/edu/jhuapl/trinity/javafx/components/radial/MainNavMenu.java index 59b5678a..832bee28 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/radial/MainNavMenu.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/radial/MainNavMenu.java @@ -48,7 +48,7 @@ public class MainNavMenu extends LitRadialMenu { public static double ITEM_FIT_WIDTH = 84.0; public static double MENU_RADIUS = 215.0; public static double OFFSET = 9.0; - public static double INITIAL_ANGLE = -120.0; + public static double INITIAL_ANGLE = -90.0; public static double STROKE_WIDTH = 1.5; static Color bgLg1Color = Color.DARKCYAN.deriveColor(1, 1, 1, 0.2); @@ -221,8 +221,11 @@ public void buildMenu() { data.setEffect(glow); ImageView hypersurface = ResourceUtils.loadIcon("hypersurface", ITEM_FIT_WIDTH); hypersurface.setEffect(glow); + + ImageView contextGraphIcon = ResourceUtils.loadIcon("graph", ITEM_FIT_WIDTH); + contextGraphIcon.setEffect(glow); - ImageView projections = ResourceUtils.loadIcon("graph", ITEM_FIT_WIDTH); + ImageView projections = ResourceUtils.loadIcon("projections", ITEM_FIT_WIDTH); projections.setEffect(glow); ImageView hyperspace = ResourceUtils.loadIcon("hyperspace", ITEM_FIT_WIDTH); @@ -238,17 +241,18 @@ public void buildMenu() { ImageView shutdown = ResourceUtils.loadIcon("shutdown", ITEM_FIT_WIDTH); shutdown.setEffect(glow); - addMenuItem(new LitRadialMenuItem(ITEM_SIZE, "Configuration", configuration, e -> { - Pane pathPane = App.getAppPathPaneStack(); - if (null == configurationPane) - configurationPane = new ConfigurationPane(scene, pathPane); - if (!pathPane.getChildren().contains(configurationPane)) - pathPane.getChildren().add(configurationPane); - configurationPane.show(); - slideInPane(configurationPane); - })); try { LitRadialContainerMenuItem systemSubMenuItem = new LitRadialContainerMenuItem(ITEM_SIZE, "System", system); + systemSubMenuItem.addMenuItem(new LitRadialMenuItem(ITEM_SIZE * 0.5, "Configuration", configuration, e -> { + Pane pathPane = App.getAppPathPaneStack(); + if (null == configurationPane) + configurationPane = new ConfigurationPane(scene, pathPane); + if (!pathPane.getChildren().contains(configurationPane)) + pathPane.getChildren().add(configurationPane); + configurationPane.show(); + slideInPane(configurationPane); + })); + systemSubMenuItem.addMenuItem(new LitRadialMenuItem(ITEM_SIZE * 0.5, "About", about, e -> { scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.SHOW_ABOUT)); Pane pathPane = App.getAppPathPaneStack(); @@ -276,6 +280,10 @@ public void buildMenu() { scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.SHOW_PROJECTIONS)); })); + addMenuItem(new LitRadialMenuItem(ITEM_SIZE * 0.5, "Context Graph", contextGraphIcon, e -> { + scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.SHOW_CONTEXT_GRAPH)); + })); + addMenuItem(new LitRadialMenuItem(ITEM_SIZE * 0.5, "Hyperspace", hyperspace, e -> { scene.getRoot().fireEvent(new ApplicationEvent(ApplicationEvent.SHOW_HYPERSPACE)); })); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/components/radial/PointDistanceMenu.java b/src/main/java/edu/jhuapl/trinity/javafx/components/radial/PointDistanceMenu.java index c719e9e7..fa1942e3 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/components/radial/PointDistanceMenu.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/components/radial/PointDistanceMenu.java @@ -1,6 +1,6 @@ package edu.jhuapl.trinity.javafx.components.radial; -import edu.jhuapl.trinity.javafx.javafx3d.Projections3DPane; +import edu.jhuapl.trinity.javafx.javafx3d.projections.Projections3DPane; import edu.jhuapl.trinity.utils.ResourceUtils; import javafx.embed.swing.SwingFXUtils; import javafx.scene.SnapshotParameters; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java b/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java index 84db70d2..e1b139a5 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/events/ApplicationEvent.java @@ -27,6 +27,7 @@ public class ApplicationEvent extends Event { public static final EventType SHOW_HYPERSPACE = new EventType(ANY, "SHOW_HYPERSPACE"); public static final EventType SHOW_HYPERSURFACE = new EventType(ANY, "SHOW_HYPERSURFACE"); public static final EventType SHOW_HYPERDRIVE_PANE = new EventType(ANY, "SHOW_HYPERDRIVE_PANE"); + public static final EventType SHOW_CONTEXT_GRAPH = new EventType(ANY, "SHOW_CONTEXT_GRAPH"); public static final EventType SHOW_PROJECTIONS = new EventType(ANY, "SHOW_PROJECTIONS"); public static final EventType SHOW_PROJECTOR_PANE = new EventType(ANY, "SHOW_PROJECTOR_PANE"); public static final EventType SHOW_BUSY_INDICATOR = new EventType(ANY, "SHOW_BUSY_INDICATOR"); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Manifold3D.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Manifold3D.java index 81b0276d..e2af2da9 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Manifold3D.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Manifold3D.java @@ -70,7 +70,7 @@ public class Manifold3D extends Group { public SimpleBooleanProperty selected = new SimpleBooleanProperty(false); public ContextMenu cm; //allows adding actions by local subscenes //allows 2D labels to track their 3D counterparts - HashMap shape3DToLabel = new HashMap<>(); + public HashMap shape3DToLabel = new HashMap<>(); AnimationTimer tessellationTimer; private List originalPoint3Ds = null; public static File latestDir = new File("."); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/ShadowCubeWorld.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/ShadowCubeWorld.java index 5e64c0d1..a0f362d7 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/ShadowCubeWorld.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/ShadowCubeWorld.java @@ -79,8 +79,8 @@ public static enum PROJECTION_TYPE {FIXED_ORTHOGRAPHIC, ROTATING_PERSPECTIVE} public boolean renderingEnabled = true; public boolean showNearsidePoints = false; //declutters view public double panelRectangleOpacity = 0.01; - PROJECTION_TYPE projectionType = ShadowCubeWorld.PROJECTION_TYPE.FIXED_ORTHOGRAPHIC; - Affine projectionAffine = new Affine(); + public PROJECTION_TYPE projectionType = ShadowCubeWorld.PROJECTION_TYPE.FIXED_ORTHOGRAPHIC; + public Affine projectionAffine = new Affine(); public boolean dirty = false; public boolean reflectY = true; public HyperspaceEvent.COLOR_MODE colorMode = HyperspaceEvent.COLOR_MODE.COLOR_BY_LABEL; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/animated/Tracer.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/animated/Tracer.java index 11b7f392..e1f41739 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/animated/Tracer.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/animated/Tracer.java @@ -106,6 +106,63 @@ public Tracer(Point3D start, Point3D end, float width, Color color) { // enableCycle(animating); } + /** Repositions this Tracer in-place, retaining its mesh, width and material. FX thread only. */ + public void setEndpoints(Point3D start, Point3D end) { + if (start == null || end == null) return; + double ox = currentEnd.x - currentStart.x; + double oy = currentEnd.y - currentStart.y; + double oz = currentEnd.z - currentStart.z; + double nx = end.x - start.x; + double ny = end.y - start.y; + double nz = end.z - start.z; + double oldLen = Math.sqrt(ox*ox + oy*oy + oz*oz); + double newLen = Math.sqrt(nx*nx + ny*ny + nz*nz); + if (oldLen < 1e-8 || newLen < 1e-8) { + setVisible(false); + return; + } + setVisible(true); + ox /= oldLen; oy /= oldLen; oz /= oldLen; + nx /= newLen; ny /= newLen; nz /= newLen; + double vx = oy*nz - oz*ny; + double vy = oz*nx - ox*nz; + double vz = ox*ny - oy*nx; + double dot = Math.max(-1, Math.min(1, ox*nx + oy*ny + oz*nz)); + double[] points = new double[mesh.getPoints().size()]; + for (int i=0; i makePointList(Point3D start, Point3D end) { List points = new ArrayList<>(2); points.add(start); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/contextgraph/ContextGraph3DPane.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/contextgraph/ContextGraph3DPane.java new file mode 100644 index 00000000..db4a01f5 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/contextgraph/ContextGraph3DPane.java @@ -0,0 +1,273 @@ +package edu.jhuapl.trinity.javafx.javafx3d.contextgraph; + +import edu.jhuapl.trinity.App; +import edu.jhuapl.trinity.javafx.components.panes.ContextGraphControlsPane; +import edu.jhuapl.trinity.javafx.events.GraphEvent; +import edu.jhuapl.trinity.javafx.events.ApplicationEvent; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.MenuItem; +import javafx.scene.control.CheckMenuItem; + +import javafx.scene.layout.Pane; +import javafx.scene.AmbientLight; +import javafx.scene.Group; +import javafx.scene.PerspectiveCamera; +import javafx.scene.Scene; +import javafx.scene.SceneAntialiasing; +import javafx.scene.SubScene; +import javafx.scene.input.MouseEvent; +import javafx.scene.Node; +import edu.jhuapl.trinity.data.graph.GraphNode; +import edu.jhuapl.trinity.data.graph.GraphEdge; +import javafx.scene.layout.StackPane; +import javafx.scene.paint.Color; +import javafx.scene.PointLight; +import org.fxyz3d.utils.CameraTransformer; +import javafx.scene.input.ScrollEvent; +import javafx.scene.input.KeyEvent; +import javafx.scene.input.KeyCode; +import javafx.geometry.Point3D; +import javafx.scene.transform.NonInvertibleTransformException; + +/** Independent 3D graph viewport. Graph data remains in the shared graph model. */ +public class ContextGraph3DPane extends StackPane { + private final Group world = new Group(); + private final CameraTransformer cameraTransform = new CameraTransformer(); + private final PerspectiveCamera camera = new PerspectiveCamera(true); + private final SubScene subScene; + private double fitGraphPercentage = 85.0; + private final ContextGraphController graphController; + private final ContextGraphInteractionController interactionController; + private ContextGraphControlsPane controlsPane; + private double anchorX; + private double anchorY; + private double dragDistance; + private String lastHoveredNodeId; + private GraphEdge lastHoveredEdge; + + public ContextGraph3DPane(Scene scene) { + graphController = new ContextGraphController(scene); + interactionController = new ContextGraphInteractionController( + scene, graphController); + camera.setNearClip(0.1); + camera.setFarClip(100000); + camera.setTranslateZ(-80); + cameraTransform.getChildren().add(camera); + cameraTransform.ry.setAngle(-45.0); + cameraTransform.rx.setAngle(-10.0); + PointLight pointLight = new PointLight(Color.WHITE); + pointLight.setTranslateZ(-900); + cameraTransform.getChildren().add(pointLight); + world.getChildren().addAll(cameraTransform, new AmbientLight(Color.color(0.65, 0.65, 0.65)), + graphController.getGraphLayer()); + subScene = new SubScene(world, 800, 600, true, SceneAntialiasing.BALANCED); + subScene.setFill(Color.rgb(8, 12, 22)); + subScene.setCamera(camera); + subScene.widthProperty().bind(widthProperty()); + subScene.heightProperty().bind(heightProperty()); + getChildren().add(subScene); + ContextMenu graphMenu = new ContextMenu(); + MenuItem controlsItem = new MenuItem("Context Graph Controls"); + controlsItem.setOnAction(e -> showGraphControls(scene)); + MenuItem fitItem = new MenuItem("Fit Graph"); + fitItem.setOnAction(e -> frameGraph(graphController.getCurrentGraph())); + MenuItem navigatorItem = new MenuItem("Content Navigator"); + navigatorItem.setOnAction(e -> scene.getRoot().fireEvent( + new ApplicationEvent(ApplicationEvent.SHOW_NAVIGATOR_PANE))); + CheckMenuItem diffuseTestItem = new CheckMenuItem("Diagnostic: Opaque Diffuse Materials"); + diffuseTestItem.setOnAction(e -> graphController.setDiagnosticDiffuseMaterials(diffuseTestItem.isSelected())); + graphMenu.getItems().addAll(controlsItem, navigatorItem, fitItem, diffuseTestItem); + subScene.setOnContextMenuRequested(e -> { + graphMenu.show(subScene, e.getScreenX(), e.getScreenY()); + e.consume(); + }); + // Geometry picking is intentionally disabled during performance recovery. + subScene.addEventHandler(MouseEvent.MOUSE_PRESSED, e -> { + anchorX = e.getSceneX(); + anchorY = e.getSceneY(); + dragDistance = 0; + }); + subScene.addEventHandler(MouseEvent.MOUSE_DRAGGED, e -> { + double dx = e.getSceneX() - anchorX; + double dy = e.getSceneY() - anchorY; + dragDistance += Math.hypot(dx, dy); + anchorX = e.getSceneX(); + anchorY = e.getSceneY(); + double modifier = e.isControlDown() ? 0.1 : e.isShiftDown() ? 25.0 : 1.0; + double factor = 0.1 * modifier; + if (e.isPrimaryButtonDown()) { + if (e.isAltDown()) { + cameraTransform.rz.setAngle(cameraTransform.rz.getAngle() + dx * factor * 2.0); + } else { + cameraTransform.ry.setAngle(cameraTransform.ry.getAngle() + dx * factor * 2.0); + cameraTransform.rx.setAngle(cameraTransform.rx.getAngle() - dy * factor * 2.0); + } + } else if (e.isMiddleButtonDown()) { + cameraTransform.t.setX(cameraTransform.t.getX() + dx * factor * 0.3); + cameraTransform.t.setY(cameraTransform.t.getY() + dy * factor * 0.3); + } + }); + // Shift+wheel is delivered as horizontal scrolling on some platforms. + // Use deltaX as a fallback rather than losing the zoom input. + subScene.setOnScroll(e -> { + double delta = e.getDeltaY(); + if (Math.abs(delta) < 1.0e-9 && e.isShiftDown()) { + delta = e.getDeltaX(); + } + if (Math.abs(delta) < 1.0e-9) return; + double modifier = e.isShiftDown() ? 100.0 : e.isControlDown() ? 1.0 : 50.0; + double nextZ = camera.getTranslateZ() + delta * 0.1 * modifier; + // Keep the camera on the negative-Z side of its orbital center. + camera.setTranslateZ(Math.min(-0.1, nextZ)); + e.consume(); + }); + // Match Projections3DPane: camera-local translation, not pivot movement. + subScene.setOnKeyPressed(e -> { + double step = e.isShiftDown() ? 100.0 : 10.0; + switch (e.getCode()) { + case W -> camera.setTranslateZ(Math.min(-0.1, camera.getTranslateZ() + step)); + case S -> camera.setTranslateZ(camera.getTranslateZ() - step); + case A -> camera.setTranslateX(camera.getTranslateX() - step); + case D -> camera.setTranslateX(camera.getTranslateX() + step); + case SPACE -> camera.setTranslateY(camera.getTranslateY() + step); + case X -> camera.setTranslateY(camera.getTranslateY() - step); + default -> { return; } + } + e.consume(); + }); + subScene.setFocusTraversable(true); + subScene.setOnMouseEntered(e -> subScene.requestFocus()); + subScene.addEventHandler(MouseEvent.MOUSE_PRESSED, e -> subScene.requestFocus()); + graphController.installEventHandlers(); + scene.addEventHandler(GraphEvent.NEW_GRAPHDIRECTED_COLLECTION, e -> { + if (e.object instanceof edu.jhuapl.trinity.data.graph.GraphDirectedCollection graph) { + long eventStart = System.nanoTime(); + System.out.printf("[CG-TIMING] graph event entered thread=%s%n", Thread.currentThread().getName()); + graphController.loadGraph(graph); + long frameStart = System.nanoTime(); + frameGraph(graph); + System.out.printf("[CG-TIMING] frameGraph %.3f ms; graph event total %.3f ms%n", (System.nanoTime()-frameStart)/1e6, (System.nanoTime()-eventStart)/1e6); + // Sample FX pulse callbacks for 15 seconds; these are NOT GPU presentation timestamps. + new javafx.animation.AnimationTimer() { + private long previousPulse = -1; + private long nextReport = eventStart + 1_000_000_000L; + private long maxGapNanos; + private int pulseCount; + @Override public void handle(long now) { + long current = System.nanoTime(); + pulseCount++; + if (previousPulse > 0) { + long gap = current - previousPulse; + if (gap > maxGapNanos) maxGapNanos = gap; + if (gap >= 250_000_000L) { + System.out.printf("[CG-TIMING] FX pulse gap %.3f ms at elapsed %.3f ms%n", + gap / 1e6, (current - eventStart) / 1e6); + } + } + previousPulse = current; + if (current >= nextReport) { + System.out.printf("[CG-TIMING] FX pulse sample elapsed=%.3f ms pulses=%d maxGap=%.3f ms%n", + (current - eventStart) / 1e6, pulseCount, maxGapNanos / 1e6); + nextReport = current + 1_000_000_000L; + } + if (current - eventStart >= 15_000_000_000L) { + System.out.printf("[CG-TIMING] FX pulse sampling finished elapsed=%.3f ms pulses=%d maxGap=%.3f ms%n", + (current - eventStart) / 1e6, pulseCount, maxGapNanos / 1e6); + stop(); + } + } + }.start(); + } + }); + interactionController.installEventHandlers(); + } + + private void showGraphControls(Scene scene) { + Pane overlay = App.getAppPathPaneStack(); + if (overlay == null) return; + if (controlsPane == null) { + controlsPane = new ContextGraphControlsPane(scene, overlay, graphController, this); + } + if (!overlay.getChildren().contains(controlsPane)) { + overlay.getChildren().add(controlsPane); + } + controlsPane.show(); + scene.getRoot().fireEvent(new GraphEvent(GraphEvent.SET_STYLE_GUI, + edu.jhuapl.trinity.utils.graph.GraphStyleParams.copyOf(graphController.getStyleParams()))); + graphController.syncVisibilityGui(); + } + + /** Percentage of viewport width and height available to the fitted graph. */ + public double getFitGraphPercentage() { + return fitGraphPercentage; + } + + public void setFitGraphPercentage(double percentage) { + if (!Double.isFinite(percentage) || percentage < 10.0 || percentage > 100.0) return; + fitGraphPercentage = percentage; + } + + private void frameGraph(edu.jhuapl.trinity.data.graph.GraphDirectedCollection graph) { + if (graph == null || graph.getNodes() == null || graph.getNodes().isEmpty()) return; + + double minX = Double.POSITIVE_INFINITY, minY = minX, minZ = minX; + double maxX = Double.NEGATIVE_INFINITY, maxY = maxX, maxZ = maxX; + for (GraphNode node : graph.getNodes()) { + if (node == null) continue; + double[] p = graphController.getDisplayedPosition(node); + if (p == null || p.length < 3 || !Double.isFinite(p[0]) + || !Double.isFinite(p[1]) || !Double.isFinite(p[2])) continue; + minX = Math.min(minX, p[0]); maxX = Math.max(maxX, p[0]); + minY = Math.min(minY, p[1]); maxY = Math.max(maxY, p[1]); + minZ = Math.min(minZ, p[2]); maxZ = Math.max(maxZ, p[2]); + } + if (!Double.isFinite(minX)) return; + + double cx = (minX + maxX) / 2.0; + double cy = (minY + maxY) / 2.0; + double cz = (minZ + maxZ) / 2.0; + cameraTransform.setTranslate(cx, cy, cz); + camera.setTranslateX(0); + camera.setTranslateY(0); + + double width = Math.max(1.0, subScene.getWidth()); + double height = Math.max(1.0, subScene.getHeight()); + double aspect = width / height; + double halfFov = Math.toRadians(camera.getFieldOfView()) / 2.0; + double tanVertical = camera.isVerticalFieldOfView() + ? Math.tan(halfFov) : Math.tan(halfFov) / aspect; + double tanHorizontal = camera.isVerticalFieldOfView() + ? Math.tan(halfFov) * aspect : Math.tan(halfFov); + double occupancy = fitGraphPercentage / 100.0; + double requiredDistance = camera.getNearClip() + 1.0; + + // Rotate each bounding-box corner into camera-local coordinates. + // Camera local +Z is forward; its position is at local Z = -distance. + // For a corner at (x,y,z), depth is distance + z. + try { + var inverse = cameraTransform.getLocalToParentTransform().createInverse(); + for (double x : new double[]{minX, maxX}) { + for (double y : new double[]{minY, maxY}) { + for (double z : new double[]{minZ, maxZ}) { + Point3D local = inverse.deltaTransform(x - cx, y - cy, z - cz); + requiredDistance = Math.max(requiredDistance, + Math.abs(local.getX()) / (tanHorizontal * occupancy) - local.getZ()); + requiredDistance = Math.max(requiredDistance, + Math.abs(local.getY()) / (tanVertical * occupancy) - local.getZ()); + requiredDistance = Math.max(requiredDistance, + camera.getNearClip() + 1.0 - local.getZ()); + } + } + } + } catch (NonInvertibleTransformException ex) { + double diagonal = Math.sqrt(Math.pow(maxX - minX, 2) + + Math.pow(maxY - minY, 2) + Math.pow(maxZ - minZ, 2)); + requiredDistance = Math.max(requiredDistance, diagonal * 2.5); + } + camera.setTranslateZ(-requiredDistance); + } + + public ContextGraphController getGraphController() { + return graphController; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/contextgraph/ContextGraphController.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/contextgraph/ContextGraphController.java new file mode 100644 index 00000000..7048037c --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/contextgraph/ContextGraphController.java @@ -0,0 +1,606 @@ +package edu.jhuapl.trinity.javafx.javafx3d.contextgraph; + +import edu.jhuapl.trinity.data.graph.GraphDirectedCollection; +import edu.jhuapl.trinity.data.graph.GraphFeatureCollectionAdapter; +import edu.jhuapl.trinity.data.graph.GraphNode; +import edu.jhuapl.trinity.data.graph.GraphEdge; +import edu.jhuapl.trinity.utils.graph.GraphLayoutParams; +import edu.jhuapl.trinity.utils.graph.ForceFrLayout3D; +import edu.jhuapl.trinity.utils.graph.GraphTopologyLayouts3D; +import edu.jhuapl.trinity.utils.graph.GraphNetworkLayouts3D; +import javafx.application.Platform; +import javafx.animation.AnimationTimer; +import javafx.concurrent.Task; +import javafx.geometry.Point3D; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import edu.jhuapl.trinity.javafx.events.CommandTerminalEvent; +import edu.jhuapl.trinity.javafx.events.GraphEvent; + +import edu.jhuapl.trinity.javafx.renderers.Graph3DRenderer; +import edu.jhuapl.trinity.utils.graph.GraphStyleParams; +import edu.jhuapl.trinity.utils.Utils; +import javafx.event.Event; +import javafx.scene.Group; +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.paint.Color; +import javafx.scene.paint.PhongMaterial; +import javafx.scene.shape.Shape3D; +import javafx.scene.image.WritableImage; +import java.util.HashMap; +import java.util.Map; +import javafx.scene.text.Font; + + +/** + * Owns the 3D graph visual subsystem hosted by the Context Graph view. + * + * The controller owns graph rendering and style state independently of other views. + * + * @author Sean Phillips + */ +public final class ContextGraphController { + + private final Scene scene; + private final Group graphLayer = new Group(); + // These groups stay attached to the SubScene for the lifetime of the viewer. + private final Group nodeLayer = new Group(); + private final Group edgeLayer = new Group(); + private boolean diagnosticDiffuseMaterials; + private final Map diffuseMaterials = new HashMap<>(); + // Test 2: all edges use one shared opaque material, independent of style. + private static final PhongMaterial TEST_EDGE_MATERIAL = new PhongMaterial(Color.CORNFLOWERBLUE); + + public boolean isDiagnosticDiffuseMaterials() { return diagnosticDiffuseMaterials; } + + /** Diagnostic only: compare the ordinary diffuse pipeline with RGBA emissive textures. */ + public void setDiagnosticDiffuseMaterials(boolean enabled) { + if (diagnosticDiffuseMaterials == enabled) return; + diagnosticDiffuseMaterials = enabled; + applyNodeMaterials(styleParams); + applyEdgeMaterials(styleParams); + } + + private PhongMaterial displayMaterial(Color color, double opacity) { + if (!diagnosticDiffuseMaterials) return emissiveMaterial(color, opacity); + // Deliberately opaque for the rendering A/B test. + Color opaque = new Color(color.getRed(), color.getGreen(), color.getBlue(), 1.0); + return diffuseMaterials.computeIfAbsent(opaque, c -> { + PhongMaterial material = new PhongMaterial(c); + material.setSpecularColor(c); + return material; + }); + } + + private boolean graphVisible = true; + private GraphDirectedCollection currentGraph; + private final Graph3DRenderer.Params graphParams = new Graph3DRenderer.Params() + .withNodeRadius(1.0) + .withNodeDivisions(16) + .withEdgeWidth(0.5f) + .withPositionScalar(1.0); + private GraphStyleParams styleParams = new GraphStyleParams(); + private boolean eventHandlersInstalled; + private final Map emissiveMaterials = new HashMap<>(); + private double coordinateScale = 1.0; + + // Coordinates are independent of the GraphNode's source vector. + private final Map layoutPositions = new HashMap<>(); + private final Map nodeViews = new HashMap<>(); + private final Map edgeViews = new java.util.IdentityHashMap<>(); + + + private GraphLayoutParams layoutParams = new GraphLayoutParams(); + private long layoutGeneration; + private volatile long renderGeneration; + private int edgeBatchSize = 100; + + public int getEdgeBatchSize() { return edgeBatchSize; } + + /** Changes the number of edges attached on the next animation pulse. FX thread only. */ + public void setEdgeBatchSize(int value) { + if (value < 1 || value > 1000) throw new IllegalArgumentException("Edge batch size must be between 1 and 1000"); + edgeBatchSize = value; + } + private AnimationTimer edgeAttachmentTimer; + + public double getCoordinateScale() { return coordinateScale; } + + public void setCoordinateScale(double value) { + if (!Double.isFinite(value) || value <= 0 || value == coordinateScale) return; + coordinateScale = value; + updateNodePositions(); + updateEdgeEndpoints(); + } + + public void finishCoordinateScale() { + updateEdgeEndpoints(); + } + + private double[] sourcePosition(GraphNode node) { + double[] position = layoutPositions.get(node.getEntityID()); + if (position != null) return position; + if (node.getVector() == null || node.getVector().size() < 3) return new double[]{0,0,0}; + return new double[]{node.getVector().get(0), node.getVector().get(1), node.getVector().get(2)}; + } + + private double[] scaledPosition(GraphNode node) { + double[] p = sourcePosition(node); + return new double[]{p[0]*coordinateScale, p[1]*coordinateScale, p[2]*coordinateScale}; + } + + private void updateNodePositions() { + if (currentGraph == null) return; + for (GraphNode node : currentGraph.getNodes()) { + ContextGraphNode3D sphere = nodeViews.get(node.getEntityID()); + if (sphere == null) continue; + double[] p = scaledPosition(node); + sphere.setTranslateX(p[0]); + sphere.setTranslateY(p[1]); + sphere.setTranslateZ(p[2]); + } + } + + private void updateEdgeEndpoints() { + if (currentGraph == null) return; + for (Map.Entry entry : edgeViews.entrySet()) { + GraphEdge edge = entry.getKey(); + Optional a = currentGraph.findNodeById(edge.getStartID()); + Optional b = currentGraph.findNodeById(edge.getEndID()); + if (a.isEmpty() || b.isEmpty()) continue; + double[] ap=scaledPosition(a.get()), bp=scaledPosition(b.get()); + entry.getValue().setEndpoints(new Point3D(ap[0],ap[1],ap[2]), + new Point3D(bp[0],bp[1],bp[2])); + } + } + + private void refreshEdges() { + long startTime = System.nanoTime(); + if (currentGraph == null || edgeLayer == null) return; + edgeLayer.getChildren().clear(); + edgeViews.clear(); + for (GraphEdge edge : currentGraph.getEdges()) { + Optional a = currentGraph.findNodeById(edge.getStartID()); + Optional b = currentGraph.findNodeById(edge.getEndID()); + if (a.isEmpty() || b.isEmpty()) continue; + double[] ap = scaledPosition(a.get()); + double[] bp = scaledPosition(b.get()); + ContextGraphEdge3D edgeView = new ContextGraphEdge3D( + (float) Math.max(0.001, styleParams.edgeWidth), 6, 1); + edgeView.setEndpoints(new Point3D(ap[0], ap[1], ap[2]), + new Point3D(bp[0], bp[1], bp[2])); + edgeViews.put(edge, edgeView); + edgeLayer.getChildren().add(edgeView); + } + System.out.printf("[CG-TIMING] edge construction + attachment %.3f ms edges=%d%n", (System.nanoTime()-startTime)/1e6, edgeLayer.getChildren().size()); + applyEdgeMaterials(styleParams); + System.out.print("ContextGraph refreshEdges: "); + Utils.printTotalTime(startTime); + } + + private void applyLayout(GraphLayoutParams params) { + if (currentGraph == null || currentGraph.getNodes().isEmpty()) return; + layoutParams = params; + final GraphDirectedCollection graph = currentGraph; + final List nodes = new ArrayList<>(graph.getNodes()); + final long generation = ++layoutGeneration; + final int n = nodes.size(); + final double[][] weights = new double[n][n]; + final Map ids = new HashMap<>(); + for (int i=0;i topologyEdges = new ArrayList<>(); + for (GraphEdge edge:graph.getEdges()) { + Integer a=ids.get(edge.getStartID()), b=ids.get(edge.getEndID()); + if(a!=null && b!=null) { weights[a][b]=1; weights[b][a]=1; topologyEdges.add(new int[]{a,b}); } + } + Thread worker = new Thread(() -> { + double[][] positions = new double[n][3]; + if (params.kind == GraphLayoutParams.LayoutKind.FORCE_FR) { + positions = new ForceFrLayout3D().layout(n, null, null, weights, params); + } else if (params.kind == GraphLayoutParams.LayoutKind.FORCE_ATLAS_3D + || params.kind == GraphLayoutParams.LayoutKind.KAMADA_KAWAI_3D + || params.kind == GraphLayoutParams.LayoutKind.STRESS_MAJOR_3D + || params.kind == GraphLayoutParams.LayoutKind.SPECTRAL_3D) { + positions = GraphNetworkLayouts3D.layout(n, topologyEdges, params); + } else if (params.kind == GraphLayoutParams.LayoutKind.RADIAL_TREE_3D + || params.kind == GraphLayoutParams.LayoutKind.LAYERED_HIERARCHY_3D + || params.kind == GraphLayoutParams.LayoutKind.HYPERBOLIC_HIERARCHY_3D) { + positions = GraphTopologyLayouts3D.layout(n, topologyEdges, params); + } else { + for(int i=0;i { + if(generation!=layoutGeneration || graph!=currentGraph) return; + layoutPositions.clear(); + for(int i=0;i { + if (e.object instanceof GraphLayoutParams p) applyLayout(p); + }); + scene.addEventHandler(GraphEvent.GRAPH_RESET_PARAMS, e -> { + layoutParams = new GraphLayoutParams(); + layoutPositions.clear(); + updateNodePositions(); + updateEdgeEndpoints(); + }); + scene.addEventHandler(GraphEvent.GRAPH_STYLE_PARAMS_CHANGED, e -> { + if (!(e.object instanceof GraphStyleParams p)) return; + + styleParams.nodeColor = p.nodeColor; + styleParams.nodeRadius = p.nodeRadius; + styleParams.nodeOpacity = clamp01(p.nodeOpacity); + styleParams.edgeColor = p.edgeColor; + styleParams.edgeWidth = p.edgeWidth; + styleParams.edgeOpacity = clamp01(p.edgeOpacity); + + applyGraphStyle(styleParams, true); + }); + + scene.addEventHandler(GraphEvent.GRAPH_STYLE_RESET_DEFAULTS, e -> { + styleParams = new GraphStyleParams(); + + graphParams.withNodeRadius(styleParams.nodeRadius) + .withEdgeWidth((float) styleParams.edgeWidth); + + if (currentGraph != null) { + rebuildGraph(); + } + applyGraphStyle(styleParams, false); + fireOnRoot(new GraphEvent(GraphEvent.SET_STYLE_GUI, styleParams)); + }); + + + scene.addEventHandler(GraphEvent.GRAPH_VISIBILITY_CHANGED, e -> { + if (!(e.object instanceof Boolean visible)) return; + setGraphVisible(visible); + }); + } + + private GraphFeatureCollectionAdapter featureAdapter; + + public GraphFeatureCollectionAdapter getFeatureAdapter() { return featureAdapter; } + + /** Loads a graph without routing through a generic callback. */ + public void loadGraph(GraphDirectedCollection graph) { + if (graph == null) return; + if (!Platform.isFxApplicationThread()) { + Platform.runLater(() -> loadGraph(graph)); + return; + } + currentGraph = graph; + ++layoutGeneration; + layoutPositions.clear(); + rebuildGraph(); + fireOnRoot(new GraphEvent(GraphEvent.SET_STYLE_GUI, styleParams)); + fireOnRoot(new GraphEvent(GraphEvent.SET_GRAPH_VISIBILITY_GUI, graphVisible)); + } + + private void setGraphVisible(boolean visible) { + graphVisible = visible; + graphLayer.setVisible(visible); + } + + /** Prepare detached geometry on a worker Task; attach edges across FX pulses. */ + private void rebuildGraph() { + // Only the layer reset below requires FX-thread ownership; the Task + // itself and all geometry preparation are independent of FX scheduling. + final long generation = ++renderGeneration; + if (edgeAttachmentTimer != null) { + edgeAttachmentTimer.stop(); + edgeAttachmentTimer = null; + } + nodeLayer.getChildren().clear(); + edgeLayer.getChildren().clear(); + nodeViews.clear(); + edgeViews.clear(); + final GraphDirectedCollection graph = currentGraph; + if (graph == null) return; + final double scale = coordinateScale; + final double nodeRadius = styleParams.nodeRadius; + final double edgeRadius = Math.max(0.001, styleParams.edgeWidth); + final int nodeDivisions = graphParams.nodeDivisions; + final PhongMaterial nodeMaterial = displayMaterial( + styleParams.nodeColor == null ? Color.CYAN : styleParams.nodeColor, + styleParams.nodeOpacity); + final PhongMaterial edgeMaterial = displayMaterial( + styleParams.edgeColor == null ? Color.CORNFLOWERBLUE : styleParams.edgeColor, + styleParams.edgeOpacity); + final long started = System.nanoTime(); + final Map layoutSnapshot = new HashMap<>(layoutPositions); + // Snapshot ordinary graph data on the worker, not the FX thread. + // This Task is started directly; it is not rescheduled onto FX. + Task task = new Task<>() { + @Override protected Void call() { + long prepareStart = System.nanoTime(); + final List graphNodes = new ArrayList<>(graph.getNodes()); + final List graphEdges = new ArrayList<>(graph.getEdges()); + final Map coordinates = new HashMap<>(); + for (GraphNode node : graphNodes) { + if (isCancelled() || generation != renderGeneration) return null; + double[] pos = layoutSnapshot.get(node.getEntityID()); + if (pos == null) { + if (node.getVector() == null || node.getVector().size() < 3) { + pos = new double[]{0, 0, 0}; + } else { + pos = new double[]{node.getVector().get(0), + node.getVector().get(1), node.getVector().get(2)}; + } + } + coordinates.put(node.getEntityID(), new double[]{ + pos[0] * scale, pos[1] * scale, pos[2] * scale}); + } + for (GraphNode node : graphNodes) { + if (isCancelled() || generation != renderGeneration) return null; + double[] pos = coordinates.get(node.getEntityID()); + ContextGraphNode3D sphere = new ContextGraphNode3D(nodeMaterial, nodeRadius, + nodeDivisions, false); + sphere.setExpansionScale(1.10); + sphere.setMouseTransparent(true); + sphere.setOnMouseMoved(null); + sphere.setOnMouseClicked(null); + sphere.setTranslateX(pos[0]); + sphere.setTranslateY(pos[1]); + sphere.setTranslateZ(pos[2]); + Platform.runLater(() -> { + if (generation != renderGeneration) return; + nodeViews.put(node.getEntityID(), sphere); + nodeLayer.getChildren().add(sphere); + }); + } + long edgeStart = System.nanoTime(); + final List preparedEdges = new ArrayList<>(); + final List preparedEdgeKeys = new ArrayList<>(); + for (GraphEdge edge : graphEdges) { + if (isCancelled() || generation != renderGeneration) return null; + double[] a = coordinates.get(edge.getStartID()); + double[] b = coordinates.get(edge.getEndID()); + if (a == null || b == null) continue; + ContextGraphEdge3D view = new ContextGraphEdge3D((float) edgeRadius, 3, 1); + view.setEndpoints(new Point3D(a[0], a[1], a[2]), + new Point3D(b[0], b[1], b[2])); + view.setMaterial(edgeMaterial); + view.setMouseTransparent(true); + preparedEdges.add(view); + preparedEdgeKeys.add(edge); + } + System.out.printf("[CG-TIMING] background geometry preparation %.3f ms; edge phase %.3f ms%n", + (System.nanoTime() - prepareStart) / 1e6, + (System.nanoTime() - edgeStart) / 1e6); + Platform.runLater(() -> { + if (generation != renderGeneration) return; + final long progressiveStart = System.nanoTime(); + edgeAttachmentTimer = new AnimationTimer() { + private int nextIndex; + private int batchCount; + private long previousPulse = -1L; + private double maxPulseGapMs; + private double maxAttachMs; + + @Override public void handle(long now) { + if (generation != renderGeneration) { + stop(); + if (edgeAttachmentTimer == this) edgeAttachmentTimer = null; + return; + } + if (previousPulse >= 0) { + double gapMs = (now - previousPulse) / 1e6; + maxPulseGapMs = Math.max(maxPulseGapMs, gapMs); + if (gapMs >= 250.0) { + System.out.printf("[CG-TIMING] progressive FX pulse gap %.3f ms edges=%d/%d%n", + gapMs, nextIndex, preparedEdges.size()); + } + } + previousPulse = now; + int end = Math.min(nextIndex + edgeBatchSize, preparedEdges.size()); + long attachStart = System.nanoTime(); + if (end > nextIndex) { + // Honor style changes made while progressive loading is active. + PhongMaterial activeMaterial = displayMaterial( + styleParams.edgeColor == null ? Color.CORNFLOWERBLUE : styleParams.edgeColor, + styleParams.edgeOpacity); + float activeWidth = (float) Math.max(0.001, styleParams.edgeWidth); + for (int i = nextIndex; i < end; i++) { + ContextGraphEdge3D pending = preparedEdges.get(i); + pending.setMaterial(activeMaterial); + if (pending.getRadius() != activeWidth) pending.setRadius(activeWidth); + } + edgeLayer.getChildren().addAll( + preparedEdges.subList(nextIndex, end)); + for (int i = nextIndex; i < end; i++) { + edgeViews.put(preparedEdgeKeys.get(i), preparedEdges.get(i)); + } + } + double attachMs = (System.nanoTime() - attachStart) / 1e6; + maxAttachMs = Math.max(maxAttachMs, attachMs); + nextIndex = end; + batchCount++; + if (nextIndex == preparedEdges.size()) { + stop(); + if (edgeAttachmentTimer == this) edgeAttachmentTimer = null; + System.out.printf("[CG-TIMING] progressive edges complete %.3f ms batches=%d batchSize=%d maxAttach=%.3f ms maxPulseGap=%.3f ms nodes=%d edges=%d%n", + (System.nanoTime() - progressiveStart) / 1e6, + batchCount, edgeBatchSize, maxAttachMs, maxPulseGapMs, + nodeLayer.getChildren().size(), edgeLayer.getChildren().size()); + System.out.printf("[CG-TIMING] all FX attachments completed %.3f ms nodes=%d edges=%d%n", + (System.nanoTime() - started) / 1e6, + nodeLayer.getChildren().size(), edgeLayer.getChildren().size()); + report("Rendered 3D graph: nodes=" + nodeViews.size() + + ", edges=" + edgeViews.size()); + } + } + }; + edgeAttachmentTimer.start(); + }); + return null; + } + }; + Thread worker = new Thread(task, "context-graph-geometry"); + worker.setDaemon(true); + worker.start(); + } + + /** Styles only the indexed graph objects. No scene-graph recursion. */ + private void applyGraphStyle(GraphStyleParams style, boolean rebuildIfNeeded) { + if (style == null) return; + long startTime = System.nanoTime(); + boolean edgeWidthChanged = Math.abs(style.edgeWidth - graphParams.edgeWidth) > 1.0e-6; + graphParams.withNodeRadius(style.nodeRadius); + if (edgeWidthChanged && rebuildIfNeeded && currentGraph != null) { + graphParams.withEdgeWidth((float) style.edgeWidth); + for (ContextGraphEdge3D edge : edgeViews.values()) { + edge.setRadius((float) Math.max(0.001, style.edgeWidth)); + } + } + applyNodeMaterials(style); + applyEdgeMaterials(style); + System.out.print("ContextGraph applyGraphStyle: "); + Utils.printTotalTime(startTime); + } + + private void applyNodeMaterials(GraphStyleParams style) { + Color color = style.nodeColor == null ? Color.CYAN : style.nodeColor; + PhongMaterial material = displayMaterial(color, style.nodeOpacity); + for (ContextGraphNode3D sphere : nodeViews.values()) { + if (sphere.getRadius() != style.nodeRadius) sphere.setSphereRadius(style.nodeRadius); + sphere.setPhongMaterial(material); + } + } + + private void applyEdgeMaterials(GraphStyleParams style) { + PhongMaterial material = displayMaterial( + style.edgeColor == null ? Color.CORNFLOWERBLUE : style.edgeColor, + style.edgeOpacity); + for (ContextGraphEdge3D edge : edgeViews.values()) { + if (edge.getMaterial() != material) edge.setMaterial(material); + } + } + + /** Current scaled coordinates, including any active graph layout. */ + public double[] getDisplayedPosition(GraphNode node) { + return scaledPosition(node); + } + + private PhongMaterial emissiveMaterial(Color color, double opacity) { + Color rgba = new Color(color.getRed(), color.getGreen(), color.getBlue(), clamp01(opacity)); + return emissiveMaterials.computeIfAbsent(rgba, key -> { + WritableImage image = new WritableImage(1, 1); + image.getPixelWriter().setColor(0, 0, key); + PhongMaterial material = new PhongMaterial(); + material.setDiffuseColor(null); + material.setDiffuseMap(null); + material.setSelfIlluminationMap(image); + return material; + }); + } + + private void fireOnRoot(Event event) { + if (scene != null && scene.getRoot() != null) { + scene.getRoot().fireEvent(event); + } + } + + private static double clamp01(double value) { + return Math.max(0.0, Math.min(1.0, value)); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/contextgraph/ContextGraphEdge3D.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/contextgraph/ContextGraphEdge3D.java new file mode 100644 index 00000000..89ab9881 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/contextgraph/ContextGraphEdge3D.java @@ -0,0 +1,125 @@ +package edu.jhuapl.trinity.javafx.javafx3d.contextgraph; + +import javafx.geometry.Point3D; +import javafx.scene.shape.CullFace; +import javafx.scene.shape.MeshView; +import javafx.scene.shape.TriangleMesh; +import javafx.scene.transform.Rotate; +import javafx.scene.transform.Scale; + +import java.util.HashMap; +import java.util.Map; + +/** One MeshView per edge, with shared unit-cylinder geometry. */ +public final class ContextGraphEdge3D extends MeshView { + private static final Map SHARED_MESHES = new HashMap<>(); + private final Rotate orientation = new Rotate(); + private final Scale dimensions = new Scale(); + private float radius; + + public ContextGraphEdge3D(float radius, int radialDivisions, int lengthDivisions) { + if (!Float.isFinite(radius) || radius <= 0f) { + throw new IllegalArgumentException("radius must be positive and finite"); + } + this.radius = radius; + // Construct the shared mesh once for each radial resolution. The caller + // creates all edge views on its existing geometry-preparation worker. + int divisions = Math.max(3, radialDivisions); + synchronized (SHARED_MESHES) { + setMesh(SHARED_MESHES.computeIfAbsent(divisions, ContextGraphEdge3D::createUnitCylinder)); + } + setCullFace(CullFace.BACK); + // Transform list is evaluated right-to-left: local dimensions first, then orientation. + getTransforms().addAll(orientation, dimensions); + dimensions.setX(radius); + dimensions.setZ(radius); + dimensions.setY(1.0); + setMouseTransparent(true); + // lengthDivisions retained for API compatibility; no axial subdivisions. + } + + public void setRadius(float radius) { + if (!Float.isFinite(radius) || radius <= 0f) { + throw new IllegalArgumentException("radius must be positive and finite"); + } + this.radius = radius; + dimensions.setX(radius); + dimensions.setZ(radius); + } + + public float getRadius() { + return radius; + } + + public void setEndpoints(Point3D start, Point3D end) { + Point3D delta = end.subtract(start); + double length = delta.magnitude(); + if (!Double.isFinite(length) || length < 1.0e-6) { + setVisible(false); + return; + } + setVisible(true); + dimensions.setY(length); + Point3D midpoint = start.midpoint(end); + setTranslateX(midpoint.getX()); + setTranslateY(midpoint.getY()); + setTranslateZ(midpoint.getZ()); + + Point3D axis = Rotate.Y_AXIS.crossProduct(delta); + if (axis.magnitude() < 1.0e-9) { + orientation.setAxis(Rotate.X_AXIS); + orientation.setAngle(delta.getY() < 0 ? 180 : 0); + } else { + orientation.setAxis(axis); + orientation.setAngle(Math.toDegrees(Math.acos( + Math.max(-1.0, Math.min(1.0, delta.getY() / length))))); + } + } + + private static TriangleMesh createUnitCylinder(int divisions) { + TriangleMesh mesh = new TriangleMesh(); + float[] points = new float[divisions * 2 * 3 + 6]; + for (int i = 0; i < divisions; i++) { + double angle = 2.0 * Math.PI * i / divisions; + float x = (float) Math.cos(angle); + float z = (float) Math.sin(angle); + int p = i * 6; + points[p] = x; + points[p + 1] = -0.5f; + points[p + 2] = z; + points[p + 3] = x; + points[p + 4] = 0.5f; + points[p + 5] = z; + } + int bottomCenter = divisions * 2; + int topCenter = bottomCenter + 1; + points[bottomCenter * 3 + 1] = -0.5f; + points[topCenter * 3 + 1] = 0.5f; + mesh.getPoints().setAll(points); + mesh.getTexCoords().setAll(0f, 0f); + + int[] faces = new int[divisions * 4 * 6]; + int cursor = 0; + for (int i = 0; i < divisions; i++) { + int next = (i + 1) % divisions; + int bi = i * 2; + int ti = bi + 1; + int bn = next * 2; + int tn = bn + 1; + // Winding points outward on the side, upward on the top, + // and downward on the bottom (JavaFX front-face convention). + int[][] triangles = { + {bi, ti, bn}, {ti, tn, bn}, + {topCenter, tn, ti}, {bottomCenter, bi, bn} + }; + for (int[] triangle : triangles) { + for (int vertex : triangle) { + faces[cursor++] = vertex; + faces[cursor++] = 0; + } + } + } + mesh.getFaces().setAll(faces); + return mesh; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/contextgraph/ContextGraphInteractionController.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/contextgraph/ContextGraphInteractionController.java new file mode 100644 index 00000000..42fd004f --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/contextgraph/ContextGraphInteractionController.java @@ -0,0 +1,106 @@ +package edu.jhuapl.trinity.javafx.javafx3d.contextgraph; + +import edu.jhuapl.trinity.data.graph.GraphDirectedCollection; +import edu.jhuapl.trinity.data.graph.GraphEdge; +import edu.jhuapl.trinity.data.graph.GraphNode; +import edu.jhuapl.trinity.javafx.events.CommandTerminalEvent; +import edu.jhuapl.trinity.javafx.events.FactorAnalysisEvent; +import edu.jhuapl.trinity.javafx.events.GraphEvent; +import javafx.scene.Scene; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** Graph-specific picking and analytical event routing, independent of Hypersurface. */ +public final class ContextGraphInteractionController { + private final Scene scene; + private final ContextGraphController graphController; + private boolean installed; + private GraphDirectedCollection indexedGraph; + private final Map indices = new HashMap<>(); + private int[][] adjacency = new int[0][]; + + public ContextGraphInteractionController(Scene scene, + ContextGraphController graphController) { + this.scene = scene; + this.graphController = graphController; + } + + public void installEventHandlers() { + if (installed || scene == null) return; + installed = true; + scene.addEventHandler(GraphEvent.GRAPH_NODE_CLICK, e -> { + if (e.object instanceof GraphNode node) { + publishVector("Graph Adjacency Row (click): " + node.getEntityID(), adjacencyRow(node)); + } + }); + scene.addEventHandler(GraphEvent.GRAPH_NODE_HOVER, e -> { + if (e.object instanceof GraphNode node) { + publishVector("Graph Adjacency Row (hover): " + node.getEntityID(), adjacencyRow(node)); + } + }); + scene.addEventHandler(GraphEvent.GRAPH_EDGE_CLICK, e -> { + if (e.object instanceof GraphEdge edge) { + publishVector("Graph Edge Weight (click): " + edge.getStartID() + + " -> " + edge.getEndID(), new Double[]{1.0}); + } + }); + scene.addEventHandler(GraphEvent.GRAPH_EDGE_HOVER, e -> { + if (!(e.object instanceof GraphEdge edge)) return; + GraphDirectedCollection graph = graphController.getCurrentGraph(); + Optional start = graph == null ? Optional.empty() + : graph.findNodeById(edge.getStartID()); + Optional end = graph == null ? Optional.empty() + : graph.findNodeById(edge.getEndID()); + scene.getRoot().fireEvent(new CommandTerminalEvent( + "Edge hover: " + start.map(GraphNode::getEntityID).orElse("?") + + " -> " + end.map(GraphNode::getEntityID).orElse("?"), + new Font("Consolas", 16), Color.ALICEBLUE)); + }); + } + + private void publishVector(String label, Double[] vector) { + if (scene.getRoot() != null) { + scene.getRoot().fireEvent(new FactorAnalysisEvent( + FactorAnalysisEvent.ANALYSIS_DATA_VECTOR, label, vector)); + } + } + + private Double[] adjacencyRow(GraphNode node) { + GraphDirectedCollection graph = graphController.getCurrentGraph(); + if (graph == null || graph.getNodes() == null || node == null) return new Double[0]; + if (graph != indexedGraph) index(graph); + Integer index = indices.get(node.getEntityID()); + if (index == null) return new Double[0]; + Double[] row = new Double[adjacency.length]; + Arrays.fill(row, 0.0); + for (int neighbor : adjacency[index]) row[neighbor] = 1.0; + return row; + } + + private void index(GraphDirectedCollection graph) { + indexedGraph = graph; + indices.clear(); + List nodes = graph.getNodes(); + for (int i = 0; i < nodes.size(); i++) { + if (nodes.get(i) != null && nodes.get(i).getEntityID() != null) + indices.put(nodes.get(i).getEntityID(), i); + } + java.util.ArrayList> links = new java.util.ArrayList<>(); + for (int i = 0; i < nodes.size(); i++) links.add(new java.util.HashSet<>()); + if (graph.getEdges() != null) for (GraphEdge edge : graph.getEdges()) { + Integer from = indices.get(edge.getStartID()); + Integer to = indices.get(edge.getEndID()); + if (from != null && to != null) links.get(from).add(to); + } + adjacency = new int[nodes.size()][]; + for (int i = 0; i < nodes.size(); i++) { + adjacency[i] = links.get(i).stream().mapToInt(Integer::intValue).toArray(); + } + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/contextgraph/ContextGraphNode3D.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/contextgraph/ContextGraphNode3D.java new file mode 100644 index 00000000..4d68b337 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/contextgraph/ContextGraphNode3D.java @@ -0,0 +1,112 @@ +package edu.jhuapl.trinity.javafx.javafx3d.contextgraph; + +import edu.jhuapl.trinity.javafx.javafx3d.animated.Selectable3D; +import javafx.scene.paint.PhongMaterial; +import javafx.scene.shape.CullFace; +import javafx.scene.shape.MeshView; +import javafx.scene.shape.TriangleMesh; +import javafx.scene.transform.Scale; + +import java.util.HashMap; +import java.util.Map; + +/** Independent graph node with shared, immutable unit-sphere geometry. */ +public final class ContextGraphNode3D extends MeshView implements Selectable3D { + private static final Map SHARED_MESHES = new HashMap<>(); + private final Scale dimensions = new Scale(); + private final ContextGraphNodeInteraction interaction; + private double radius; + + public ContextGraphNode3D(PhongMaterial material, double radius, int divisions, boolean animateOnHover) { + int resolution = Math.max(4, divisions); + synchronized (SHARED_MESHES) { + setMesh(SHARED_MESHES.computeIfAbsent(resolution, ContextGraphNode3D::createUnitSphere)); + } + setCullFace(CullFace.BACK); + getTransforms().add(dimensions); + setSphereRadius(radius); + interaction = new ContextGraphNodeInteraction(this, material, DEFAULT_SELECTED_MATERIAL, animateOnHover); + } + + public void setSphereRadius(double value) { + if (!Double.isFinite(value) || value <= 0) throw new IllegalArgumentException("Invalid node radius"); + radius = value; + dimensions.setX(value); + dimensions.setY(value); + dimensions.setZ(value); + } + + public double getSphereRadius() { return radius; } + public double getRadius() { return radius; } + public void setRadius(double value) { setSphereRadius(value); } + public void setPhongMaterial(PhongMaterial material) { interaction.setNormalMaterial(material); } + public void setSelectedMaterial(PhongMaterial material) { interaction.setSelectedMaterial(material); } + public void setAnimateOnHover(boolean enabled) { interaction.setHoverEnabled(enabled); } + public boolean isAnimateOnHover() { return interaction.isHoverEnabled(); } + public void setExpansionScale(double value) { interaction.setExpansionScale(value); } + public double getExpansionScale() { return interaction.getExpansionScale(); } + public void setAnimationTimeMS(double value) { interaction.setAnimationTimeMS(value); } + public double getAnimationTimeMS() { return interaction.getAnimationTimeMS(); } + @Override public void select() { interaction.select(); } + @Override public void unselect() { interaction.unselect(); } + @Override public boolean isSelected() { return interaction.isSelected(); } + + private static TriangleMesh createUnitSphere(int divisions) { + int longitude = divisions; + int latitude = Math.max(3, divisions / 2); + int ringCount = latitude - 1; + int vertexCount = ringCount * longitude + 2; + float[] points = new float[vertexCount * 3]; + float[] texCoords = new float[vertexCount * 2]; + for (int ring = 1; ring < latitude; ring++) { + double theta = Math.PI * ring / latitude; + double y = Math.cos(theta); + double r = Math.sin(theta); + for (int lon = 0; lon < longitude; lon++) { + double phi = 2 * Math.PI * lon / longitude; + int index = (ring - 1) * longitude + lon; + points[index * 3] = (float) (r * Math.cos(phi)); + points[index * 3 + 1] = (float) y; + points[index * 3 + 2] = (float) (r * Math.sin(phi)); + texCoords[index * 2] = (float) lon / longitude; + texCoords[index * 2 + 1] = (float) ring / latitude; + } + } + int north = ringCount * longitude; + int south = north + 1; + points[north * 3 + 1] = 1; + points[south * 3 + 1] = -1; + texCoords[north * 2] = 0.5f; + texCoords[south * 2] = 0.5f; + texCoords[south * 2 + 1] = 1; + int triangleCount = 2 * longitude * (latitude - 1); + int[] faces = new int[triangleCount * 6]; + int cursor = 0; + for (int lon = 0; lon < longitude; lon++) { + int next = (lon + 1) % longitude; + cursor = triangle(faces, cursor, north, next, lon); + for (int ring = 0; ring < ringCount - 1; ring++) { + int a = ring * longitude + lon; + int b = ring * longitude + next; + int c = (ring + 1) * longitude + lon; + int d = (ring + 1) * longitude + next; + cursor = triangle(faces, cursor, a, b, c); + cursor = triangle(faces, cursor, b, d, c); + } + int last = (ringCount - 1) * longitude; + cursor = triangle(faces, cursor, last + lon, last + next, south); + } + TriangleMesh mesh = new TriangleMesh(); + mesh.getPoints().setAll(points); + mesh.getTexCoords().setAll(texCoords); + mesh.getFaces().setAll(faces); + return mesh; + } + + private static int triangle(int[] faces, int cursor, int a, int b, int c) { + faces[cursor++] = a; faces[cursor++] = a; + faces[cursor++] = b; faces[cursor++] = b; + faces[cursor++] = c; faces[cursor++] = c; + return cursor; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/contextgraph/ContextGraphNodeInteraction.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/contextgraph/ContextGraphNodeInteraction.java new file mode 100644 index 00000000..3bf4dcf6 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/contextgraph/ContextGraphNodeInteraction.java @@ -0,0 +1,85 @@ +package edu.jhuapl.trinity.javafx.javafx3d.contextgraph; + +import javafx.animation.Interpolator; +import javafx.animation.ScaleTransition; +import javafx.scene.paint.Material; +import javafx.scene.paint.PhongMaterial; +import javafx.scene.shape.Shape3D; +import javafx.util.Duration; + +/** Per-node interaction state; does not own or modify shared geometry. */ +final class ContextGraphNodeInteraction { + private final Shape3D node; + private Material normalMaterial; + private Material selectedMaterial; + private boolean selected; + private boolean hoverEnabled; + private double expansionScale = 1.10; + private double animationTimeMS = 100; + private ScaleTransition transition; + + ContextGraphNodeInteraction(Shape3D node, Material normalMaterial, Material selectedMaterial, + boolean hoverEnabled) { + this.node = node; + this.normalMaterial = normalMaterial; + this.selectedMaterial = selectedMaterial; + this.hoverEnabled = hoverEnabled; + node.setMaterial(normalMaterial); + node.setOnMouseEntered(event -> { if (this.hoverEnabled) animate(expansionScale); }); + node.setOnMouseExited(event -> { if (this.hoverEnabled) animate(1.0); }); + } + + void setNormalMaterial(Material material) { + normalMaterial = material; + if (!selected) node.setMaterial(material); + } + + void setSelectedMaterial(PhongMaterial material) { + selectedMaterial = material; + if (selected) node.setMaterial(material); + } + + void select() { + selected = true; + node.setMaterial(selectedMaterial); + } + + void unselect() { + selected = false; + node.setMaterial(normalMaterial); + } + + boolean isSelected() { return selected; } + void setHoverEnabled(boolean enabled) { + hoverEnabled = enabled; + if (!enabled) { + if (transition != null) transition.stop(); + node.setScaleX(1); + node.setScaleY(1); + node.setScaleZ(1); + } + } + boolean isHoverEnabled() { return hoverEnabled; } + void setExpansionScale(double value) { + if (!Double.isFinite(value) || value < 1.0 || value > 1.10) { + throw new IllegalArgumentException("Context Graph hover scale must be in [1.0, 1.10]"); + } + expansionScale = value; + } + double getExpansionScale() { return expansionScale; } + void setAnimationTimeMS(double value) { + if (!Double.isFinite(value) || value < 0) throw new IllegalArgumentException("Invalid duration"); + animationTimeMS = value; + } + double getAnimationTimeMS() { return animationTimeMS; } + + private void animate(double target) { + if (transition != null) transition.stop(); + transition = new ScaleTransition(Duration.millis(animationTimeMS), node); + transition.setToX(target); + transition.setToY(target); + transition.setToZ(target); + transition.setInterpolator(Interpolator.EASE_OUT); + transition.play(); + } +} diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/contextgraph/TubeMesh.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/contextgraph/TubeMesh.java new file mode 100644 index 00000000..27739a48 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/contextgraph/TubeMesh.java @@ -0,0 +1,377 @@ +package edu.jhuapl.trinity.javafx.javafx3d.contextgraph; + +import javafx.scene.shape.TriangleMesh; + +/** + * TubeMesh + * + * Open cylinder (tube without caps) TriangleMesh oriented along +Y. + * Base at y=0, top at y=height. + * + * Features: + * - Seam-aware UVs with duplicate seam column. + * - UV tiling updates via setTiling(...). + * - Per-frame UV scrolling via setVOffset / setUOffset using a single + * setAll(...) update to avoid per-element flicker. + * - Optional inner wall: a second set of faces wound in the opposite + * direction sharing the same point positions. The inner wall mirrors + * the U coordinate (tilesAround - u) so the texture reads correctly + * when viewed from inside. + * - WindingOrder control for the outer wall. The inner wall always uses + * the opposite winding to the outer wall. + * + * Winding: + * Default CCW for the outer wall when viewed from outside. + * With innerWall=true, the inner wall is automatically wound CW + * (opposite the outer wall) so it faces inward. + */ +public class TubeMesh extends TriangleMesh { + + private int radialDivisions; + private int lengthDivisions; + + private int cols; // radialDivisions + 1 (duplicate seam column) + private int rows; // lengthDivisions + 1 + + private float radius; + private float height; + + private float tilesAround = 1f; + private float tilesAlong = 1f; + + private boolean innerWall = false; + private WindingOrder windingOrder = WindingOrder.CCW; + + // Base UV arrays — outer and inner walls have separate arrays because + // inner wall mirrors U. Both exclude scroll offsets. + private float[] baseTexCoordsOuter; + private float[] baseTexCoordsInner; + + // Working UV array (outer + inner concatenated) used for setAll() + private float[] workTexCoords; + + private float uOffset = 0f; + private float vOffset = 0f; + + // --- Constructors -------------------------------------------------------- + + public TubeMesh(float radius, float height, int radialDivisions, int lengthDivisions) { + this(radius, height, radialDivisions, lengthDivisions, 1f, 1f, false, WindingOrder.CCW); + } + + public TubeMesh(float radius, float height, int radialDivisions, int lengthDivisions, + WindingOrder windingOrder) { + this(radius, height, radialDivisions, lengthDivisions, 1f, 1f, false, windingOrder); + } + + public TubeMesh(float radius, float height, int radialDivisions, int lengthDivisions, + float tilesAround, float tilesAlong) { + this(radius, height, radialDivisions, lengthDivisions, tilesAround, tilesAlong, false, WindingOrder.CCW); + } + + public TubeMesh(float radius, float height, int radialDivisions, int lengthDivisions, + float tilesAround, float tilesAlong, WindingOrder windingOrder) { + this(radius, height, radialDivisions, lengthDivisions, tilesAround, tilesAlong, false, windingOrder); + } + + public TubeMesh(float radius, float height, int radialDivisions, int lengthDivisions, + float tilesAround, float tilesAlong, boolean innerWall) { + this(radius, height, radialDivisions, lengthDivisions, tilesAround, tilesAlong, innerWall, WindingOrder.CCW); + } + + public TubeMesh(float radius, float height, int radialDivisions, int lengthDivisions, + float tilesAround, float tilesAlong, boolean innerWall, WindingOrder windingOrder) { + super(); + if (radius <= 0f) throw new IllegalArgumentException("radius must be > 0"); + if (height <= 0f) throw new IllegalArgumentException("height must be > 0"); + + this.radius = radius; + this.height = height; + this.radialDivisions = Math.max(3, radialDivisions); + this.lengthDivisions = Math.max(1, lengthDivisions); + this.tilesAround = (tilesAround > 0f) ? tilesAround : 1f; + this.tilesAlong = (tilesAlong > 0f) ? tilesAlong : 1f; + this.innerWall = innerWall; + this.windingOrder = windingOrder; + + rebuildAll(); + } + + /** Updates tube dimensions in the existing point buffer without changing topology. */ + public void setDimensions(float newRadius, float newHeight) { + if (newRadius <= 0 || newHeight <= 0) throw new IllegalArgumentException("positive dimensions required"); + if (radius == newRadius && height == newHeight) return; + radius = newRadius; + height = newHeight; + buildPoints(); + } + + // --- Public API ---------------------------------------------------------- + + public int getRadialDivisions() { return radialDivisions; } + public int getLengthDivisions() { return lengthDivisions; } + public float getRadius() { return radius; } + public float getHeight() { return height; } + public float getTilesAround() { return tilesAround; } + public float getTilesAlong() { return tilesAlong; } + public boolean isInnerWall() { return innerWall; } + + public WindingOrder getWindingOrder() { return windingOrder; } + public void setWindingOrder(WindingOrder windingOrder) { this.windingOrder = windingOrder; } + + public void setInnerWall(boolean innerWall) { this.innerWall = innerWall; } + + public float getUOffset() { return uOffset; } + public float getVOffset() { return vOffset; } + + /** Per-frame friendly: apply a U offset via a single setAll(). */ + public void setUOffset(float uOffset) { + this.uOffset = uOffset; + applyOffsetsToLiveTexCoords(); + } + + /** Per-frame friendly: apply a V offset via a single setAll(). */ + public void setVOffset(float vOffset) { + this.vOffset = vOffset; + applyOffsetsToLiveTexCoords(); + } + + /** Convenience: set both offsets at once (single setAll()). */ + public void setUVOffset(float uOffset, float vOffset) { + this.uOffset = uOffset; + this.vOffset = vOffset; + applyOffsetsToLiveTexCoords(); + } + + /** + * Occasional update: change UV tiling and rebuild texcoords only. + * Not suitable for per-frame use. + */ + public void setTiling(float tilesAround, float tilesAlong) { + if (tilesAround <= 0f) tilesAround = 1f; + if (tilesAlong <= 0f) tilesAlong = 1f; + if (this.tilesAround == tilesAround && this.tilesAlong == tilesAlong) return; + this.tilesAround = tilesAround; + this.tilesAlong = tilesAlong; + buildTexCoords(); + applyOffsetsToLiveTexCoords(); + } + + /** + * Full rebuild: update geometry parameters and reconstruct all buffers. + * Do not call every frame. + */ + public void rebuildGeometry(float radius, float height, int radialDivisions, int lengthDivisions) { + if (radius <= 0f) throw new IllegalArgumentException("radius must be > 0"); + if (height <= 0f) throw new IllegalArgumentException("height must be > 0"); + + boolean changed = this.radius != radius + || this.height != height + || this.radialDivisions != Math.max(3, radialDivisions) + || this.lengthDivisions != Math.max(1, lengthDivisions); + + if (!changed) return; + + this.radius = radius; + this.height = height; + this.radialDivisions = Math.max(3, radialDivisions); + this.lengthDivisions = Math.max(1, lengthDivisions); + + rebuildAll(); + } + + /** Rebuild all buffers using current stored parameters. */ + public void rebuild() { + rebuildAll(); + } + + // --- Build internals ----------------------------------------------------- + + private void rebuildAll() { + this.cols = radialDivisions + 1; + this.rows = lengthDivisions + 1; + + buildPoints(); + buildTexCoords(); + buildFaces(); + buildSmoothingGroups(); + applyOffsetsToLiveTexCoords(); + } + + private void buildPoints() { + // Shared by both outer and inner walls — same physical positions. + // index = row * cols + col + float[] points = new float[rows * cols * 3]; + int p = 0; + + for (int row = 0; row < rows; row++) { + float y = ((float) row / lengthDivisions) * height; + + for (int col = 0; col < cols; col++) { + double theta = 2.0 * Math.PI * ((double) col / radialDivisions); + points[p++] = (float) (radius * Math.cos(theta)); + points[p++] = y; + points[p++] = (float) (radius * Math.sin(theta)); + } + } + + getPoints().setAll(points); + } + + private void buildTexCoords() { + // Outer wall texcoords: U increases with col (standard) + // Inner wall texcoords: U mirrors — (tilesAround - u) so texture + // reads correctly when viewed from inside. + // + // Both walls use the same V coordinate. + // Layout: outer block first, then inner block (if innerWall). + // Each block is rows * cols UV pairs. + + final int blockSize = rows * cols * 2; + final int totalSize = innerWall ? blockSize * 2 : blockSize; + + baseTexCoordsOuter = new float[blockSize]; + int t = 0; + + for (int row = 0; row < rows; row++) { + float v = ((float) row / lengthDivisions) * tilesAlong; + for (int col = 0; col < cols; col++) { + float u = ((float) col / radialDivisions) * tilesAround; + baseTexCoordsOuter[t++] = u; + baseTexCoordsOuter[t++] = v; + } + } + + if (innerWall) { + baseTexCoordsInner = new float[blockSize]; + t = 0; + for (int row = 0; row < rows; row++) { + float v = ((float) row / lengthDivisions) * tilesAlong; + for (int col = 0; col < cols; col++) { + // Mirror U so inner surface texture reads correctly + float u = tilesAround - ((float) col / radialDivisions) * tilesAround; + baseTexCoordsInner[t++] = u; + baseTexCoordsInner[t++] = v; + } + } + } else { + baseTexCoordsInner = null; + } + + // Allocate/resize work buffer + if (workTexCoords == null || workTexCoords.length != totalSize) { + workTexCoords = new float[totalSize]; + } + + // Initialise live texcoords to base (offsets applied below) + System.arraycopy(baseTexCoordsOuter, 0, workTexCoords, 0, blockSize); + if (innerWall) { + System.arraycopy(baseTexCoordsInner, 0, workTexCoords, blockSize, blockSize); + } + getTexCoords().setAll(workTexCoords); + } + + private void buildFaces() { + final int quadsPerWall = lengthDivisions * radialDivisions; + final int trisPerWall = quadsPerWall * 2; + final int wallCount = innerWall ? 2 : 1; + final int[] faces = new int[trisPerWall * wallCount * 6]; + + int f = 0; + + // Outer wall + final boolean invertOuter = windingOrder == WindingOrder.CW; + f = buildWallFaces(faces, f, 0, invertOuter); + + // Inner wall — opposite winding to outer, UV offset by one block + if (innerWall) { + final int uvOffset = rows * cols; // inner UVs start after outer block + final boolean invertInner = !invertOuter; + f = buildWallFaces(faces, f, uvOffset, invertInner); + } + + getFaces().setAll(faces); + } + + /** + * Writes quad-pair face entries for one wall into the faces array. + * + * @param faces target array + * @param fi write offset into faces + * @param uvBase UV index offset (0 for outer wall, rows*cols for inner) + * @param invert true to invert winding for this wall + * @return updated fi + */ + private int buildWallFaces(int[] faces, int fi, int uvBase, boolean invert) { + for (int row = 0; row < lengthDivisions; row++) { + for (int col = 0; col < radialDivisions; col++) { + int p00 = row * cols + col; + int p01 = row * cols + (col + 1); + int p10 = (row + 1) * cols + col; + int p11 = (row + 1) * cols + (col + 1); + + int t00 = uvBase + p00; + int t01 = uvBase + p01; + int t10 = uvBase + p10; + int t11 = uvBase + p11; + + // Triangle 1: p00, p10, p01 (CCW from outside) + faces[fi++] = p00; faces[fi++] = t00; + faces[fi++] = invert ? p01 : p10; faces[fi++] = invert ? t01 : t10; + faces[fi++] = invert ? p10 : p01; faces[fi++] = invert ? t10 : t01; + + // Triangle 2: p01, p10, p11 (CCW from outside) + faces[fi++] = p01; faces[fi++] = t01; + faces[fi++] = invert ? p11 : p10; faces[fi++] = invert ? t11 : t10; + faces[fi++] = invert ? p10 : p11; faces[fi++] = invert ? t10 : t11; + } + } + return fi; + } + + private void buildSmoothingGroups() { + final int trisPerWall = lengthDivisions * radialDivisions * 2; + final int wallCount = innerWall ? 2 : 1; + final int faceCount = trisPerWall * wallCount; + + int[] sm = new int[faceCount]; + // Both walls share smoothing group 1 — continuous smooth shading + for (int i = 0; i < faceCount; i++) sm[i] = 1; + getFaceSmoothingGroups().setAll(sm); + } + + // --- Offset application -------------------------------------------------- + + /** + * Push baseTexCoords + scroll offsets into live texCoords via a single setAll(). + * Avoids per-element mutations that cause visual flicker. + */ + private void applyOffsetsToLiveTexCoords() { + if (baseTexCoordsOuter == null) return; + + final int blockSize = baseTexCoordsOuter.length; + final int totalSize = innerWall ? blockSize * 2 : blockSize; + + if (workTexCoords == null || workTexCoords.length != totalSize) { + workTexCoords = new float[totalSize]; + } + + final float uo = uOffset; + final float vo = vOffset; + + for (int i = 0; i < blockSize; i += 2) { + workTexCoords[i] = baseTexCoordsOuter[i] + uo; + workTexCoords[i + 1] = baseTexCoordsOuter[i + 1] + vo; + } + + if (innerWall && baseTexCoordsInner != null) { + for (int i = 0; i < blockSize; i += 2) { + // Mirror the U offset direction for the inner wall + workTexCoords[blockSize + i] = baseTexCoordsInner[i] - uo; + workTexCoords[blockSize + i + 1] = baseTexCoordsInner[i + 1] + vo; + } + } + + getTexCoords().setAll(workTexCoords); + } +} \ No newline at end of file diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/contextgraph/WindingOrder.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/contextgraph/WindingOrder.java new file mode 100644 index 00000000..91d9e62f --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/contextgraph/WindingOrder.java @@ -0,0 +1,3 @@ +package edu.jhuapl.trinity.javafx.javafx3d.contextgraph; + +public enum WindingOrder { CCW, CW } diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/DirectedMeshHelper.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hyperspace/DirectedMeshHelper.java similarity index 98% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/DirectedMeshHelper.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hyperspace/DirectedMeshHelper.java index 5a5f91df..b0db844f 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/DirectedMeshHelper.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hyperspace/DirectedMeshHelper.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hyperspace; import javafx.scene.shape.TriangleMesh; import org.fxyz3d.geometry.Point3D; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/DirectedScatterDataModel.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hyperspace/DirectedScatterDataModel.java similarity index 99% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/DirectedScatterDataModel.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hyperspace/DirectedScatterDataModel.java index f5e54ab1..70438189 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/DirectedScatterDataModel.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hyperspace/DirectedScatterDataModel.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hyperspace; import org.fxyz3d.geometry.Point3D; import org.slf4j.Logger; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/DirectedScatterMesh.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hyperspace/DirectedScatterMesh.java similarity index 99% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/DirectedScatterMesh.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hyperspace/DirectedScatterMesh.java index a06bfece..812e94f4 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/DirectedScatterMesh.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hyperspace/DirectedScatterMesh.java @@ -19,7 +19,7 @@ */ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hyperspace; import javafx.beans.property.BooleanProperty; import javafx.beans.property.DoubleProperty; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/DirectedTetrahedraMesh.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hyperspace/DirectedTetrahedraMesh.java similarity index 99% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/DirectedTetrahedraMesh.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hyperspace/DirectedTetrahedraMesh.java index 4f879e6e..63c8215c 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/DirectedTetrahedraMesh.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hyperspace/DirectedTetrahedraMesh.java @@ -19,7 +19,7 @@ */ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hyperspace; import javafx.beans.property.DoubleProperty; import javafx.beans.property.IntegerProperty; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/DirectedTexturedMesh.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hyperspace/DirectedTexturedMesh.java similarity index 99% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/DirectedTexturedMesh.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hyperspace/DirectedTexturedMesh.java index d6965121..8b9b7397 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/DirectedTexturedMesh.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hyperspace/DirectedTexturedMesh.java @@ -19,7 +19,7 @@ */ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hyperspace; import javafx.beans.property.DoubleProperty; import javafx.beans.property.IntegerProperty; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/FloatCollector.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hyperspace/FloatCollector.java similarity index 98% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/FloatCollector.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hyperspace/FloatCollector.java index 66ed416b..0f977a33 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/FloatCollector.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hyperspace/FloatCollector.java @@ -27,7 +27,7 @@ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hyperspace; import java.util.Arrays; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hyperspace3DPane.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hyperspace/Hyperspace3DPane.java similarity index 99% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hyperspace3DPane.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hyperspace/Hyperspace3DPane.java index 0ff852ae..a201346e 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Hyperspace3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hyperspace/Hyperspace3DPane.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hyperspace; import edu.jhuapl.trinity.css.StyleResourceProvider; import edu.jhuapl.trinity.data.CoordinateSet; @@ -33,7 +33,12 @@ import edu.jhuapl.trinity.javafx.events.ShadowEvent; import edu.jhuapl.trinity.javafx.events.TimelineEvent; import edu.jhuapl.trinity.javafx.events.TrajectoryEvent; +import edu.jhuapl.trinity.javafx.javafx3d.Manifold3D; +import edu.jhuapl.trinity.javafx.javafx3d.ShadowCubeWorld; import edu.jhuapl.trinity.javafx.javafx3d.ShadowCubeWorld.PROJECTION_TYPE; +import edu.jhuapl.trinity.javafx.javafx3d.Trajectory3D; +import edu.jhuapl.trinity.javafx.javafx3d.TriaxialSpheroidMesh; +import edu.jhuapl.trinity.javafx.javafx3d.XFormGroup; import edu.jhuapl.trinity.javafx.javafx3d.animated.Opticon; import edu.jhuapl.trinity.javafx.javafx3d.images.ImageResourceProvider; import edu.jhuapl.trinity.javafx.javafx3d.tasks.AffinityClusterTask; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Perspective3DNode.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hyperspace/Perspective3DNode.java similarity index 99% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Perspective3DNode.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hyperspace/Perspective3DNode.java index 07188018..b2ce4d19 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Perspective3DNode.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hyperspace/Perspective3DNode.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.hyperspace; import edu.jhuapl.trinity.data.FactorLabel; import edu.jhuapl.trinity.data.FeatureLayer; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/Hypersurface3DPane.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/Hypersurface3DPane.java index 9e339d30..284a6960 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/Hypersurface3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/Hypersurface3DPane.java @@ -257,7 +257,7 @@ public Hypersurface3DPane(Scene scene) { configurePaneLayout(); configureContextMenu(); - graphController.installEventHandlers(); + // Graph event ownership has moved to ContextGraph3DPane. loadSurf3D(); registerSceneEventHandlers(); } @@ -299,7 +299,7 @@ private void configureSceneGraph() { // The graph is still temporarily hosted by Hypersurface, but its visual // layer/state is owned by HypersurfaceGraphController so it can later migrate // to a dedicated graph 3D pane/SubScene. - sceneRoot.getChildren().add(graphController.getGraphLayer()); + // Legacy surface controllers still reference this layer; it is no longer rendered here. sceneRoot.getChildren().add(surfaceCrosshairOverlay); graphController.syncVisibilityGui(); subScene.setCamera(camera); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceInteractionController.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceInteractionController.java index 13328bf6..1066dd17 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceInteractionController.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/hypersurface/HypersurfaceInteractionController.java @@ -91,7 +91,7 @@ public HypersurfaceInteractionController(Hypersurface3DPane pane, this.coordinateMapper = coordinateMapper; this.surfaceCrosshairOverlay = surfaceCrosshairOverlay; this.overlayManager = overlayManager; - wireGraphInteractionHandlers(); + // Graph-wide picking now belongs to ContextGraphInteractionController. } /** diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/AsteroidFieldPane.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/projections/AsteroidFieldPane.java similarity index 99% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/AsteroidFieldPane.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/projections/AsteroidFieldPane.java index 916edf67..94230143 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/AsteroidFieldPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/projections/AsteroidFieldPane.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.projections; import edu.jhuapl.trinity.App; import edu.jhuapl.trinity.data.Distance; @@ -11,7 +11,13 @@ import edu.jhuapl.trinity.javafx.events.HyperspaceEvent; import edu.jhuapl.trinity.javafx.events.ManifoldEvent; import edu.jhuapl.trinity.javafx.events.ShadowEvent; +import edu.jhuapl.trinity.javafx.javafx3d.Crosshair3D; +import edu.jhuapl.trinity.javafx.javafx3d.Manifold3D; +import edu.jhuapl.trinity.javafx.javafx3d.ShadowCubeWorld; import edu.jhuapl.trinity.javafx.javafx3d.ShadowCubeWorld.PROJECTION_TYPE; +import edu.jhuapl.trinity.javafx.javafx3d.Trajectory3D; +import edu.jhuapl.trinity.javafx.javafx3d.TriaxialSpheroidMesh; +import edu.jhuapl.trinity.javafx.javafx3d.XFormGroup; import edu.jhuapl.trinity.javafx.javafx3d.animated.AnimatedSphere; import edu.jhuapl.trinity.javafx.javafx3d.images.ImageResourceProvider; import edu.jhuapl.trinity.javafx.javafx3d.projectiles.HitShape3D; @@ -134,7 +140,7 @@ public class AsteroidFieldPane extends StackPane { //allows 2D labels to track their 3D counterparts HashMap shape3DToLabel = new HashMap<>(); - AnimatedSphere centralBody; + public AnimatedSphere centralBody; // initial rotation private final Rotate rotateX = new Rotate(0, Rotate.X_AXIS); diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Projections3DPane.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/projections/Projections3DPane.java similarity index 99% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Projections3DPane.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/projections/Projections3DPane.java index 4599b3bc..dc8c3561 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/Projections3DPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/projections/Projections3DPane.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.projections; import com.github.trinity.supermds.SuperMDS; import edu.jhuapl.trinity.App; @@ -40,6 +40,11 @@ import edu.jhuapl.trinity.javafx.events.ShadowEvent; import edu.jhuapl.trinity.javafx.events.TimelineEvent; import edu.jhuapl.trinity.javafx.events.TrajectoryEvent; +import edu.jhuapl.trinity.javafx.javafx3d.Crosshair3D; +import edu.jhuapl.trinity.javafx.javafx3d.Manifold3D; +import edu.jhuapl.trinity.javafx.javafx3d.Trajectory3D; +import edu.jhuapl.trinity.javafx.javafx3d.TriaxialSpheroidMesh; +import edu.jhuapl.trinity.javafx.javafx3d.XFormGroup; import edu.jhuapl.trinity.javafx.javafx3d.animated.AnimatedSphere; import edu.jhuapl.trinity.javafx.javafx3d.animated.CameraOrbiter; import edu.jhuapl.trinity.javafx.javafx3d.animated.Opticon; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/ProjectorNodeGroup.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/projections/ProjectorNodeGroup.java similarity index 99% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/ProjectorNodeGroup.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/projections/ProjectorNodeGroup.java index 70b4c281..aa3ddeeb 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/ProjectorNodeGroup.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/projections/ProjectorNodeGroup.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.projections; import edu.jhuapl.trinity.javafx.components.projector.ProjectorNode; import edu.jhuapl.trinity.javafx.components.projector.ProjectorRow; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/ProjectorPane.java b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/projections/ProjectorPane.java similarity index 99% rename from src/main/java/edu/jhuapl/trinity/javafx/javafx3d/ProjectorPane.java rename to src/main/java/edu/jhuapl/trinity/javafx/javafx3d/projections/ProjectorPane.java index 933edf94..f83c9ba0 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/ProjectorPane.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/javafx3d/projections/ProjectorPane.java @@ -1,4 +1,4 @@ -package edu.jhuapl.trinity.javafx.javafx3d; +package edu.jhuapl.trinity.javafx.javafx3d.projections; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/src/main/java/edu/jhuapl/trinity/javafx/renderers/Graph3DRenderer.java b/src/main/java/edu/jhuapl/trinity/javafx/renderers/Graph3DRenderer.java index a1ff9471..a7d7ecb3 100644 --- a/src/main/java/edu/jhuapl/trinity/javafx/renderers/Graph3DRenderer.java +++ b/src/main/java/edu/jhuapl/trinity/javafx/renderers/Graph3DRenderer.java @@ -70,7 +70,16 @@ public Params withDefaultEdgeColor(Color c) { private Graph3DRenderer() { } + /** Builds only graph node geometry for viewers that manage edges separately. */ + public static Group buildGraphNodeGroup(GraphDirectedCollection graph, Params params) { + return buildGraphGroupInternal(graph, params, false); + } + public static Group buildGraphGroup(GraphDirectedCollection graph, Params params) { + return buildGraphGroupInternal(graph, params, true); + } + + private static Group buildGraphGroupInternal(GraphDirectedCollection graph, Params params, boolean includeEdges) { Group root = new Group(); if (graph == null) { return root; @@ -92,7 +101,6 @@ public static Group buildGraphGroup(GraphDirectedCollection graph, Params params s.setTranslateX(p3.x); s.setTranslateY(p3.y); s.setTranslateZ(p3.z); - s.setUserData(gN); // --- Inspect events s.setOnMouseMoved(e -> { @@ -112,6 +120,7 @@ public static Group buildGraphGroup(GraphDirectedCollection graph, Params params } // Edges + if (includeEdges) { List edges = new ArrayList<>(graph.getEdges().size()); for (GraphEdge ge : graph.getEdges()) { Optional a = graph.findNodeById(ge.getStartID()); @@ -123,7 +132,6 @@ public static Group buildGraphGroup(GraphDirectedCollection graph, Params params Point3D pb = JavaFX3DUtils.getGraphNodePoint3D(b.get(), p.positionScalar); Color ec = (ge.getColor() != null) ? Color.valueOf(ge.getColor()) : edgeDefault; Tracer t = new Tracer(pa, pb, p.edgeWidth, ec); - t.setUserData(ge); // --- Inspect events t.setOnMouseMoved(e -> { @@ -142,8 +150,9 @@ public static Group buildGraphGroup(GraphDirectedCollection graph, Params params edges.add(t); } - root.getChildren().addAll(nodes); root.getChildren().addAll(edges); + } + root.getChildren().addAll(nodes); return root; } diff --git a/src/main/java/edu/jhuapl/trinity/utils/JavaFX3DUtils.java b/src/main/java/edu/jhuapl/trinity/utils/JavaFX3DUtils.java index 3189e49b..8623406e 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/JavaFX3DUtils.java +++ b/src/main/java/edu/jhuapl/trinity/utils/JavaFX3DUtils.java @@ -4,7 +4,7 @@ import edu.jhuapl.trinity.data.graph.GraphNode; import edu.jhuapl.trinity.data.messages.xai.FeatureVector; import edu.jhuapl.trinity.javafx.events.CommandTerminalEvent; -import edu.jhuapl.trinity.javafx.javafx3d.Perspective3DNode; +import edu.jhuapl.trinity.javafx.javafx3d.hyperspace.Perspective3DNode; import edu.jhuapl.trinity.javafx.javafx3d.Trajectory3D; import edu.jhuapl.trinity.javafx.javafx3d.animated.TessellationMesh; import javafx.animation.Interpolator; diff --git a/src/main/java/edu/jhuapl/trinity/utils/ResourceUtils.java b/src/main/java/edu/jhuapl/trinity/utils/ResourceUtils.java index c04fdf9c..a3125696 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/ResourceUtils.java +++ b/src/main/java/edu/jhuapl/trinity/utils/ResourceUtils.java @@ -663,7 +663,7 @@ public static String detectDropType(DragEvent event) { } else if (ShapleyCollectionFile.isShapleyCollectionFile(file)) { type = "Hypersurface"; } else if (GraphDirectedCollectionFile.isGraphDirectedCollectionFile(file)) { - type = "Hypersurface"; + type = "Context Graph"; } else if (VectorMaskCollectionFile.isVectorMaskCollectionFile(file)) { type = "Hypersurface"; } else if (LabelConfigFile.isLabelConfigFile(file)) { diff --git a/src/main/java/edu/jhuapl/trinity/utils/VisibilityMap.java b/src/main/java/edu/jhuapl/trinity/utils/VisibilityMap.java index deb1a1b1..c1ce48da 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/VisibilityMap.java +++ b/src/main/java/edu/jhuapl/trinity/utils/VisibilityMap.java @@ -1,6 +1,6 @@ package edu.jhuapl.trinity.utils; -import edu.jhuapl.trinity.javafx.javafx3d.Perspective3DNode; +import edu.jhuapl.trinity.javafx.javafx3d.hyperspace.Perspective3DNode; import java.util.ArrayList; import java.util.HashMap; diff --git a/src/main/java/edu/jhuapl/trinity/utils/graph/GraphLayoutParams.java b/src/main/java/edu/jhuapl/trinity/utils/graph/GraphLayoutParams.java index d68be5b3..3ce0b508 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/graph/GraphLayoutParams.java +++ b/src/main/java/edu/jhuapl/trinity/utils/graph/GraphLayoutParams.java @@ -23,7 +23,14 @@ public enum LayoutKind { CIRCLE_XY, SPHERE, MDS_3D, - FORCE_FR + FORCE_FR, + RADIAL_TREE_3D, + LAYERED_HIERARCHY_3D, + HYPERBOLIC_HIERARCHY_3D, + FORCE_ATLAS_3D, + KAMADA_KAWAI_3D, + STRESS_MAJOR_3D, + SPECTRAL_3D } /** @@ -44,6 +51,10 @@ public enum LayoutKind { * Number of force iterations (ticks). */ public int iterations = 500; + /** Number of graph-distance landmarks used by approximate stress layouts. */ + public int landmarkCount = 32; + /** Reproducible initialization for stochastic network layouts. */ + public long randomSeed = 714L; /** * Global step size / temperature start (will cool). */ diff --git a/src/main/java/edu/jhuapl/trinity/utils/graph/GraphNetworkLayouts3D.java b/src/main/java/edu/jhuapl/trinity/utils/graph/GraphNetworkLayouts3D.java new file mode 100644 index 00000000..7fbfc3a4 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/graph/GraphNetworkLayouts3D.java @@ -0,0 +1,127 @@ +package edu.jhuapl.trinity.utils.graph; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; + +/** Graph-topology-only 3D layouts. Input edges are undirected for these methods. */ +public final class GraphNetworkLayouts3D { + private GraphNetworkLayouts3D() { } + public static double[][] layout(int n, List edges, GraphLayoutParams p) { + List> adj = new ArrayList<>(n); + for (int i=0;i()); + for (int[] e:edges) { + if(e.length<2 || e[0]<0 || e[1]<0 || e[0]>=n || e[1]>=n || e[0]==e[1]) continue; + adj.get(e[0]).add(e[1]); adj.get(e[1]).add(e[0]); + } + return switch(p.kind) { + case FORCE_ATLAS_3D -> forceAtlas(adj,p); + case KAMADA_KAWAI_3D -> stress(adj,p,true); + case STRESS_MAJOR_3D -> stress(adj,p,false); + case SPECTRAL_3D -> spectral(adj,p); + default -> throw new IllegalArgumentException("Unsupported network layout: "+p.kind); + }; + } + private static double[][] seed(int n, double radius, long randomSeed) { + double[][] pos=new double[n][3]; + Random rng=new Random(randomSeed); + for(int i=0;i> adj, GraphLayoutParams p) { + int n=adj.size(); double[][] pos=seed(n,Math.max(1,p.radius),p.randomSeed); + if(n==0) return pos; + double[][] delta=new double[n][3]; Random rng=new Random(p.randomSeed ^ 187L); + int samples=Math.min(96,Math.max(0,n-1)); + int ticks=Math.max(1,p.iterations); + for(int iter=0;iteri) { + double dist2=1e-5; for(int d=0;d<3;d++) {double x=pos[j][d]-pos[i][d];dist2+=x*x;} + double dist=Math.sqrt(dist2), attraction=Math.min(10,Math.max(.001,p.attraction)*dist); + for(int d=0;d<3;d++) {double f=attraction*(pos[j][d]-pos[i][d])/dist;delta[i][d]+=f;delta[j][d]-=f;} + } + for(int k=0;k=i) j++; + double dist2=1e-4; for(int d=0;d<3;d++) {double x=pos[i][d]-pos[j][d];dist2+=x*x;} + double rep=Math.min(50,Math.max(.001,p.repulsion)*.005/dist2); + for(int d=0;d<3;d++) delta[i][d]+=rep*(pos[i][d]-pos[j][d]); + } + } + double rate=Math.max(.0001,p.step)*Math.pow(Math.min(.9999,Math.max(.01,p.cooling)),iter); + for(int i=0;i> adj,GraphLayoutParams p,boolean kk) { + int n=adj.size();double[][] pos=seed(n,Math.max(1,p.radius),p.randomSeed); if(n==0)return pos; + int landmarks=Math.min(n,Math.max(1,p.landmarkCount)); + int[][] hops=new int[landmarks][]; + int[] anchors=new int[landmarks]; + for(int k=0;k0?pos[i][d]*(1-alpha)+alpha*f[d]/mass:pos[i][d]; + } + double[][] tmp=pos;pos=next;next=tmp; + } + normalize(pos,p.radius);return pos; + } + private static int[] bfs(List> adj,int root) { + int n=adj.size();int[] dist=new int[n],q=new int[n];Arrays.fill(dist,-1); + int head=0,tail=0;q[tail++]=root;dist[root]=0; + while(head> adj,GraphLayoutParams p) { + int n=adj.size();double[][] pos=new double[n][3];if(n==0)return pos; + Random random=new Random(p.randomSeed); + double[][] axes=new double[3][n]; + double[] constant=new double[n];for(int i=0;i1e-12)for(int i=0;i1e-10)for(double[] v:pos)for(int d=0;d<3;d++)v[d]*=Math.max(1,radius)/max; + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/graph/GraphStyleParams.java b/src/main/java/edu/jhuapl/trinity/utils/graph/GraphStyleParams.java index 1261405d..2e1fdaca 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/graph/GraphStyleParams.java +++ b/src/main/java/edu/jhuapl/trinity/utils/graph/GraphStyleParams.java @@ -15,12 +15,12 @@ public final class GraphStyleParams implements Serializable { // Nodes public Color nodeColor = Color.CYAN; // default aligned with Graph3DRenderer.Params - public double nodeRadius = 20.0; + public double nodeRadius = 1.0; public double nodeOpacity = 1.0; // [0..1] // Edges public Color edgeColor = Color.ALICEBLUE; // default aligned with Graph3DRenderer.Params - public double edgeWidth = 8.0; // rendered as float in Tracer + public double edgeWidth = 0.5; // rendered as float in Tracer public double edgeOpacity = 1.0; // [0..1] // Fluent setters diff --git a/src/main/java/edu/jhuapl/trinity/utils/graph/GraphTopologyLayouts3D.java b/src/main/java/edu/jhuapl/trinity/utils/graph/GraphTopologyLayouts3D.java new file mode 100644 index 00000000..60a79d65 --- /dev/null +++ b/src/main/java/edu/jhuapl/trinity/utils/graph/GraphTopologyLayouts3D.java @@ -0,0 +1,84 @@ +package edu.jhuapl.trinity.utils.graph; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; + +/** Deterministic topology-driven layouts; original graph edges are not modified. */ +public final class GraphTopologyLayouts3D { + private GraphTopologyLayouts3D() { } + + /** Edges are node-index pairs. A directed spanning forest is derived from them. */ + public static double[][] layout(int n, List edges, GraphLayoutParams params) { + double[][] xyz = new double[n][3]; + if (n == 0) return xyz; + List> out = new ArrayList<>(n); + int[] indegree = new int[n]; + for (int i=0;i()); + for (int[] e: edges) { + if(e[0]<0 || e[1]<0 || e[0]>=n || e[1]>=n || e[0]==e[1]) continue; + out.get(e[0]).add(e[1]); indegree[e[1]]++; + } + for (List neighbors:out) neighbors.sort(Integer::compare); + Integer[] order = new Integer[n]; + for (int i=0;iindegree[i]).thenComparingInt(i->i)); + int[] parent=new int[n], depth=new int[n]; + Arrays.fill(parent,-1); + boolean[] seen=new boolean[n]; + List roots=new ArrayList<>(); + ArrayDeque queue=new ArrayDeque<>(); + for(int root:order) { + if(seen[root]) continue; + roots.add(root); seen[root]=true; queue.add(root); + while(!queue.isEmpty()) { + int u=queue.removeFirst(); + for(int v:out.get(u)) if(!seen[v]) { + seen[v]=true; parent[v]=u; depth[v]=depth[u]+1; queue.addLast(v); + } + } + } + List> children=new ArrayList<>(n); + for(int i=0;i()); + for(int i=0;i=0) children.get(parent[i]).add(i); + int[] size=new int[n]; + for(int root:roots) subtree(root,children,size); + double radius=Math.max(1.0,params.radius); + double span=2*Math.PI/roots.size(); + for(int i=0;i> children,int[] size) { + int s=1; + for(int v:children.get(u)) s+=subtree(v,children,size); + size[u]=s; return s; + } + private static void place(int u,double lo,double hi,int[] depth,List> children, + int[] size,double[][] xyz,GraphLayoutParams.LayoutKind kind,double radius) { + double a=(lo+hi)/2, d=depth[u]+1; + if(kind==GraphLayoutParams.LayoutKind.LAYERED_HIERARCHY_3D) { + xyz[u]=new double[]{radius*Math.cos(a),d*radius*0.45,radius*Math.sin(a)}; + } else if(kind==GraphLayoutParams.LayoutKind.HYPERBOLIC_HIERARCHY_3D) { + // Poincare ball projection: tanh(hyperbolic radius / 2). + double rho=Math.tanh(d*0.72/2.0)*radius; + double elevation=0.35*Math.sin(a*1.7); + xyz[u]=new double[]{rho*Math.cos(a)*Math.cos(elevation),rho*Math.sin(elevation),rho*Math.sin(a)*Math.cos(elevation)}; + } else { + double r=d*radius*0.38; + xyz[u]=new double[]{r*Math.cos(a),r*Math.sin(a),d*radius*0.12}; + } + int total=0; + for(int v:children.get(u)) total+=size[v]; + double cursor=lo; + for(int v:children.get(u)) { + double next=cursor+(hi-lo)*size[v]/Math.max(1,total); + place(v,cursor,next,depth,children,size,xyz,kind,radius); + cursor=next; + } + } +} diff --git a/src/main/java/edu/jhuapl/trinity/utils/loaders/GraphDirectedCollectionLoader.java b/src/main/java/edu/jhuapl/trinity/utils/loaders/GraphDirectedCollectionLoader.java index 50a11d3a..d111649d 100644 --- a/src/main/java/edu/jhuapl/trinity/utils/loaders/GraphDirectedCollectionLoader.java +++ b/src/main/java/edu/jhuapl/trinity/utils/loaders/GraphDirectedCollectionLoader.java @@ -53,9 +53,15 @@ protected Void call() throws Exception { }); try { + long parseStart = System.nanoTime(); GraphDirectedCollectionFile graphFile = new GraphDirectedCollectionFile(file.getAbsolutePath(), true); - Platform.runLater(() -> scene.getRoot().fireEvent( - new GraphEvent(GraphEvent.NEW_GRAPHDIRECTED_COLLECTION, graphFile.graphDirectedCollection))); + System.out.printf("[CG-TIMING] file parse completed %.3f ms thread=%s%n", (System.nanoTime()-parseStart)/1e6, Thread.currentThread().getName()); + long queuedAt = System.nanoTime(); + Platform.runLater(() -> { + System.out.printf("[CG-TIMING] graph event FX queue wait %.3f ms%n", (System.nanoTime()-queuedAt)/1e6); + scene.getRoot().fireEvent( + new GraphEvent(GraphEvent.NEW_GRAPHDIRECTED_COLLECTION, graphFile.graphDirectedCollection)); + }); } catch (IOException ex) { LOG.error(null, ex); } diff --git a/src/main/resources/edu/jhuapl/trinity/icons/projections.png b/src/main/resources/edu/jhuapl/trinity/icons/projections.png new file mode 100644 index 00000000..4539d824 Binary files /dev/null and b/src/main/resources/edu/jhuapl/trinity/icons/projections.png differ diff --git a/src/test/java/edu/jhuapl/trinity/javafx/javafx3d/AsteroidsTest.java b/src/test/java/edu/jhuapl/trinity/javafx/javafx3d/AsteroidsTest.java index d42d57bb..3a107d8b 100644 --- a/src/test/java/edu/jhuapl/trinity/javafx/javafx3d/AsteroidsTest.java +++ b/src/test/java/edu/jhuapl/trinity/javafx/javafx3d/AsteroidsTest.java @@ -1,5 +1,6 @@ package edu.jhuapl.trinity.javafx.javafx3d; +import edu.jhuapl.trinity.javafx.javafx3d.projections.AsteroidFieldPane; import edu.jhuapl.trinity.data.Manifold; import edu.jhuapl.trinity.data.messages.P3D; import edu.jhuapl.trinity.data.messages.xai.ManifoldData; 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 2750c8a1..7b557c79 100644 --- a/src/test/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceWarpTest.java +++ b/src/test/java/edu/jhuapl/trinity/javafx/javafx3d/SurfaceWarpTest.java @@ -1,5 +1,8 @@ package edu.jhuapl.trinity.javafx.javafx3d; +import edu.jhuapl.trinity.javafx.javafx3d.hyperspace.Perspective3DNode; +import edu.jhuapl.trinity.javafx.javafx3d.hyperspace.DirectedScatterMesh; +import edu.jhuapl.trinity.javafx.javafx3d.hyperspace.DirectedScatterDataModel; import com.github.sarxos.webcam.Webcam; import edu.jhuapl.trinity.data.HyperspaceSeed; import edu.jhuapl.trinity.javafx.events.ColorMapEvent;