feat(web): V2 unified 3D renderer (ADR-702 phase 1)#360
Merged
Conversation
Registers a new force-3d-v2 explorer alongside V1 so both coexist in the registry during migration. Adds: - @react-three/fiber + @react-three/drei dependencies - VisualizationType 'force-3d-v2' + /explore/3d-v2 route - ForceGraph3DV2/ with types (EngineNode, EngineEdge, V2Settings), data transformer (RawGraphData → engine shape, with undirected degree computation), placeholder component (r3f Canvas + data summary overlay), placeholder ProfilePanel Scene primitives, physics, interaction, and widgets land in subsequent M1-M5 tasks. The scaffold type-checks cleanly and the V2 route responds 200 in the running dev container.
…ges (ADR-702 M1 #7) Ports the atlassian-graph reference scene to TypeScript under ForceGraph3DV2/scene/: - positions.ts — seeded sphere positions; shared Float32Array buffer that physics (M2) will mutate in place - Nodes.tsx — InstancedMesh icosahedrons, per-instance matrix + color, scale by sqrt(degree), hidden/highlighted via per-instance scale - Edges.tsx — indexed lineSegments with per-vertex color, endpoint- gradient coloring from the palette, hidden endpoints collapse to zero-length segments - Scene.tsx — composition (lights + Nodes + Edges + OrbitControls) Wires the V2 main component to render the scene with a per-dataset ontology color scale. No physics yet (M2 task #8/#9); positions are seeded once and nodes stay static apart from camera rotation. Type check passes. Verified rendering against a live kg dataset in the dev container.
Match V1's sizing pattern — `relative w-full h-full` on the root + `position: absolute; inset: 0` on the Canvas itself. r3f's internal ResizeObserver picks up parent size changes so the canvas resizes with the browser window.
Ports useForceSim to TypeScript — O(N²) repulsion + edge attraction + center gravity with alpha-decay demand-mode pumping. Sim now owns the positions buffer (previously seeded by Scene); Nodes and Edges read the same ref. Scene exposes a simHandleRef so plugin-level UI can drive reheat/freeze/simmer from outside the Canvas tree. Pre-alpha force capping and static-friction clamp mirror the planned GPU shader so CPU and GPU paths will produce agreeing layouts. Type check clean; HMR applies without errors. Nodes now animate into a force-directed layout and settle when alpha decays below alphaMin.
…02 M2 #9) Ports useGpuForceSim to TypeScript with GPUComputationRenderer running repulsion + edge attraction + center gravity across fragment shaders. Neighbor adjacency is encoded as a CSR-style texture pair; hidden nodes are signalled via a per-node mask updated in place without rebuilding the sim. Position readback is one frame behind GPU state to keep CPU and GPU overlapped. Capability detection at module load (WebGL2 + EXT_color_buffer_float). A new useSim.ts dispatches at module scope to GPU or CPU, satisfying hooks rules with a stable hook identity. Scene switches from the direct CPU import to the dispatcher. Overlay badge in the main component shows the selected backend so we can eyeball which path fired at runtime (expect GPU on mid-range workstations, CPU on integrated graphics or older Firefox/Safari). CPU and GPU paths share the same uniforms, alpha decay, and force cap discipline, so layouts agree within numerical tolerance.
Detects multigraph bundles by canonicalized endpoint pair and distributes each edge in a bundle across a perpendicular offset range so parallel relationships fan out. Per-frame sampling walks a quadratic bezier with control point offset from the midpoint by `curveOffset * edgeLen` along a perpendicular direction (world up × edge direction, with a fallback for near-vertical edges). Geometry selects segment count at memo time: straight 1-segment when the whole graph has no bundles, 12-segment bezier otherwise. Straight edges inside a curved geometry (bundle mid-slot or non-paralleled) walk the straight line but still emit every segment so the per-vertex color table stays aligned. Hidden endpoints collapse every vertex to a single point, matching the prior behavior. Scratch Vector3s are hoisted outside the loop so frame cost stays flat at 10k-edge scale.
Instanced cones at the target end of each edge, apex pointing along the local tangent (straight line for non-parallel edges, bezier tangent at u=1 for curved bundle members). One draw call for all arrows via InstancedMesh, color matches the target node's category. Bundle offsets computed via the shared computeBundles helper (factored out of Edges.tsx into scene/bundles.ts so Arrows reads the same perpendicular direction at the same magnitude the edge renderer used). Togglable through the showArrows engine prop; default true.
…12) Adds edgePalette?: (edgeType: string) => string to Edges and Arrows. When present, edges render as flat-colored lines keyed by relationship_type instead of endpoint-category gradient, and arrow glyphs take the same edge-type color. When absent, both fall back to endpoint-category coloring (existing behavior preserved). ForceGraph3DV2 plugin builds its edgePalette by composing the vocabulary store (relationship_type → category) with the kg categoryColors lookup (category → hex). Gated by a new settings.visual.edgeColorBy field — default 'type' for kg, flippable to 'endpoint' for users who prefer the gradient. Completes ADR-702 spike finding #1 — edge semantics are now visible in V2 as first-class color information distinct from node-category coloring.
Nodes now handles pointer-over / pointer-out / click / context-menu
events on the instancedMesh and emits engine-level signals
{onHover, onSelect, onContextMenu}. Selection toggles when clicking
the already-selected node; hidden nodes ignore pointer events. Scene
passes the signals through unchanged; the plugin holds local
selectedId/hoveredId state for now (M5 #17 lifts to widgets).
Overlay shows the selected or hovered node's human label to confirm
picking resolves id → label correctly (spike finding #3 in action).
onNodeClick from the plugin contract is wired up so the existing
ExplorerView pipeline (navigation, history, reports) fires on V2
selections.
Ports the atlassian-graph CaretMarker pattern: <Html center> with four corner brackets + center ring at the selected node's world position. Constant pixel size regardless of camera distance; follows the node via a group transform updated in useFrame so there are no per-frame React re-renders. Adds NodeLabel — same follower pattern rendering node.label (not node.id) above the selected/hovered node, confirming spike finding #3. Selected variant gets a brighter border than hover. Both live in scene/Overlays.tsx and plug into Scene so the plugin layer stays clean. Html overlays don't z-sort against scene depth (documented ADR trade-off — acceptable for selection and short labels).
Renders relationship_type text as Html overlays at edge midpoints for edges within labelVisibilityRadius of the camera. Unmounts labels outside the radius rather than just hiding them; re-scans the visible set every ~200ms (throttled from the frame loop) so DOM mount/unmount stays bounded. Inside the visible set, each slot's world position updates per frame via a group-follower ref, so there's no React re-render at 60 Hz. Curved (bezier) edges compute their label position at u=0.5 on the bezier, not on the straight midpoint, so the label sits on the curve. Hidden endpoints drop the label from the visible set. Cap at 80 simultaneously-mounted labels (closest-first) so dense clusters don't explode DOM. Label border color uses the edge-type palette when available. Plugin exposes showLabels and labelVisibilityRadius through settings.visual.
Adds node repositioning via left-drag and hide via right-click. Sim hooks (CPU + GPU) now accept pinnedIds in addition to hiddenIds. Both sets freeze the node's integration step in the position loop; pinned nodes (unlike hidden) remain visible and pickable. The GPU path merges both sets into the existing hiddenMask texture so the shader already does the right thing — no shader changes needed. Nodes tracks pointer-down vs drag with a 4-pixel threshold: drags emit onDragStart / onDragMove / onDragEnd while clicks still hit onSelect. The r3f pointer-capture machinery delivers move/up even when the cursor leaves the sphere's hit region. useDragHandler builds the handler triple: on drag start it computes a plane through the node perpendicular to the camera and raycasts cursor position onto that plane each move, writing straight to the shared positions buffer. On drag end the node is unpinned. Permanent pinning can layer on later via a plugin-side toggle. Right-click on a node emits onContextMenu, which the plugin temporarily wires to hide the node. M5 task #17 swaps this for the shared ContextMenu component with expand / hide / send-to-reports. File hygiene: useGpuForceSim.ts split — shader strings out to gpuShaders.ts, CSR builder out to neighborCsr.ts. Main hook drops from ~510 lines to ~370.
Drei's <Html> renders an outer wrapper div in the DOM that doesn't inherit `pointerEvents: 'none'` from the style prop — events were being absorbed before they reached the instancedMesh raycast, so hover and click silently broke as soon as #14 added the selection caret and #15 added edge labels. Fix: set wrapperClass="pointer-events-none" (tailwind utility) on every <Html> in Overlays and EdgeLabels, plus inline pointerEvents: 'none' on their content divs as belt-and-suspenders. Nodes now receive onPointerOver/Down/Up without interference.
Two fixes: 1. Multi-edge bundles now use distinct curve planes. computeBundles returns per-edge angle (radians, rotated around the edge axis) and magnitude instead of a signed scalar offset. Edges.tsx/Arrows.tsx/ EdgeLabels.tsx build an orthonormal perpendicular basis (e1, e2) and offset the control point by cos(θ)·e1 + sin(θ)·e2 — so parallel edges fan into different planes around the edge axis rather than stacking on one perpendicular direction. A bundle of two now has one edge bulging along e1 and the other along e2; a bundle of N distributes across [0, π) so the curves are visually distinct from any camera angle. 2. Nodes refreshes InstancedMesh.boundingSphere every 15 frames by nulling it. three.js's raycaster early-outs against the cached bounding sphere, which was computed once at mount (instances near origin) and never updated as the sim spread them outward — the bounding sphere stayed tiny and pointer events silently stopped firing. Refreshing restores hover/click/drag/right-click on nodes.
Adds "3D Force Graph (V2)" to the Explorers sidebar so users can reach V2 without typing the URL, with an exact-match `isActive` on the V1 3D item so the two don't both light up. Overlay now reports both the `data` prop counts (after V2's transformer) and `rawGraphData` counts from the graph store. If the store has nodes/links but `data` is empty, the V2 dataTransformer path isn't running. This gives a quick at-a-glance diagnostic for data-pipeline issues when switching between V1 and V2. V2 already consumes the same pipeline as V1 — ExplorerView runs `explorerPlugin.dataTransformer(rawGraphData)` into store.graphData on every (rawGraphData, explorerPlugin, explorerType) change, so the graph cache is shared. This commit surfaces that rather than changing the flow.
The diagnostic overlay's store selector returned `{rn, rl}` — a fresh
object each call — which zustand treats as a changed value, causing a
re-render → selector → new object → re-render loop until React bailed
with "Maximum update depth exceeded". V2 component never got to render
stably.
Fix: select primitives individually. Two useGraphStore calls, each
returning a number, stable by identity.
…safety
Switching between explorers (e.g. V2 3D → V1 2D) crashed because
graphData was transformed in a useEffect and written to zustand. On
the first render after the switch, the new explorer component mounted
reading the *previous* explorer's shape — V1 2D reading data.links
against V2's {nodes, edges} output hit "Cannot read properties of
undefined (reading 'filter')".
Swap the useEffect for a useMemo that runs during render, so each
explorer always receives data in its own dataTransformer's shape.
The store mirror (for report creation and any other cross-cutting
readers) becomes eventual-consistent via a follow-up useEffect.
Users can now round-trip freely between 2D, 3D, 3D-V2, and Document
Explorer against the same rawGraphData cache without losing or
corrupting state.
Adds a third diagnostic line showing whether the received `data` prop is the V2 engine shape (has `edges`), a leaked V1 shape (has `links` — means the per-plugin transformer path in ExplorerView didn't fire), null, or unknown. Helps isolate whether the data pipeline or the rendering path is the source when V2 looks empty after switching from another explorer.
The bundle-plane-rotation refactor split the perpendicular-basis helper into bundles.ts but I forgot to add it to Arrows' imports. Bezier-path arrows crashed on every frame with "ReferenceError: perpendicularBasis is not defined".
Replaces the M4 right-click-hide placeholder with the full shared context menu used by V1: Follow, Add Adjacent, Remove, Pin/Unpin, Focus, Set/Clear Origin + Destination, Send Concept/Path Reports, Polarity Axis. Node-context and background-context (not yet wired for canvas-empty right-clicks) both flow through buildContextMenuItems. Uses useGraphNavigation for Follow/Add/Remove handlers so V2 hits the same store mutations as V1. Pin helpers wire to V2's pinnedIds set, which is already used by the drag handler — right-clicking to pin a dragged node is an idempotent add. Camera-tween path travel and origin/destination ring markers are V1-3D-specific and deferred; the rest of the menu works identically. Part one of three for M5 widget integration.
Clicking a node now opens the shared NodeInfoBox anchored to the node via a drei <Html> that follows its world position each frame — same collapsible-sections body as V1's info boxes (overview / evidence / relationships) so the panel pulls the same detailed concept data. activeNodeInfos is a flat array keyed by nodeId; dismiss removes from the array. Multiple selections stack. NodeInfoOverlay lives under scene/ so the follower useFrame runs inside the Canvas tree; the plugin stays DOM-level. Part two of three for M5 widget integration.
Adds the left PanelStack with Legend and the right PanelStack with
StatsPanel + Send-to-Reports, matching V1's panel layout.
Legend consumes a V1-shape GraphData synthesized from the engine
shape each render — Legend needs {nodes[*].{group,color},
links[*].{category,color}} and our EngineNode/EngineEdge compute
colors in-shader rather than carrying them on the record. The
adapter applies the same palette/edgePalette the scene uses so the
legend swatches match what's on screen.
StatsPanel reads the post-transform counts directly from data.
Completes M5 #17 — V2 now has feature parity with V1's panel + menu
surface area: NodeInfoBox on select, ContextMenu with Follow / Add /
Remove / Pin / Focus / Origin / Destination / Reports, Legend,
StatsPanel, Reports button. EdgeInfoBox and the M4 hide feature are
still V2-only additions; background-click context menu comes later.
Theme: subscribe to useThemeStore.appliedTheme and drive the Canvas background from explorerTheme.canvas3D[appliedTheme]. Light theme gets the warm cream (#ede8e4), dark gets the warm charcoal (#1f1b19) — matches V1's 3D canvas tones. Edge-category filter: subscribe to store.filters.visibleEdgeCategories and filter the engine's edge list before passing to Scene. Uses the same vocabStore.getCategory mapping as the edge palette so the Legend's category toggles drive what V2 shows. Empty-set (default) skips the filter pass entirely — no allocation on the common path. Filtered data also feeds Legend and StatsPanel so the visible counts reflect the filtered view. Touchpoint highlighting (from block query builder) is deferred — kg doesn't yet emit touchpoints into the explorer plugin props in V1 either, so V2 waits for that surface to be defined before wiring.
Replaces the V2 placeholder ProfilePanel with V2SettingsPanel — a collapsible-sections UI against the engine's own settings shape: - Physics: enabled toggle, repulsion / attraction / centerGravity / damping sliders with live slider ranges from types.ts - Visual: showArrows / showLabels toggles, edgeColorBy selector (type vs endpoint), nodeSize / linkWidth / labelVisibilityRadius sliders - Interaction: enableDrag, highlightNeighbors toggles ExplorerView now honors the ExplorerPlugin settingsPanel contract for V2 (ADR-034): when explorerType is force-3d-v2, delegate to the plugin's own panel. V1 2D/3D continue to use GraphSettingsPanel (hardcoded to V1 settings shape). Future explorers with divergent settings shapes should declare their own settingsPanel and be whitelisted the same way, or we can flip the default to plugin.settingsPanel and mark V1 as the exception. Physics sliders now feed through to the sim hook via Scene's physics prop — repulsion/attraction/centerGravity/damping update uniforms every frame on the GPU path, and apply immediately on the CPU path. Reheat/simmer/freeze sim controls are deferred to a follow-up. ProfilePanel.tsx removed — superseded by V2SettingsPanel.
V2's filter used a 2-level fallback (vocab → 'Uncategorized') while
V1's transformForD3 uses 3 levels (vocab → API category → fallback).
When Legend had been populated from V1 with API-provided categories,
V2's filter computed 'Uncategorized' for those same edges and
visibleEdgeCategories.has('Uncategorized') returned false — all
edges filtered out.
Extract the resolution into an edgeCategory() useCallback shared by
the filter and the Legend-data synthesis so the two never drift. V1
and V2 now produce identical categories for identical edges.
Captures: branch state (25 commits), M1-M5 milestone completion summary with commit refs, M6 scope (benchmark harness, 1k-concept scale spike, cutover) with concrete implementation notes, deferred items (touchpoint highlighting, background right-click context menu, camera travel, EdgeInfoBox, sim controls, drag-pin persistence), dev-environment reminders, and a step-by-step resume recipe. Lives on the branch so a future session pulling the branch finds the context. Project memory updated to point at it.
Major polish pass on the V2 3D force-graph explorer. Shared infrastructure: - Extract `computeNodeColors(nodes, edges, mode)` to common/, used by both 2D and V2 for ontology/degree/centrality coloring. 2D refactored to use it. - Add optional Show/Hide section to Legend with arrow / edge-label / node-label toggles. Backwards-compatible (section only renders when visibility controls are passed). V2 rendering: - 3D mesh edge labels (PlaneGeometry + CanvasTexture, oriented along the edge with camera-facing roll). Real depth ordering via depthTest, no Html bounding-rect clipping. Tunable size + above-line offset. - New 3D node labels — distance-culled billboards, screen-aligned (text stays horizontal under camera tilt), text colored by node color. - Smaller arrow heads with apex offset back along the tangent by the target node's actual world radius so cones touch the sphere surface. - Refactor engine props from `palette: (category) => string` to `colors: string[]` indexed by node position — engine now color-source agnostic, supports any future dimension without prop changes. V2 settings consistency: - Add `nodeColorBy` setting + dropdown (ontology/degree/centrality). - Wire `physics.enabled` through both CPU and GPU sim hooks (skips step + frame pump when off). - Wire `interaction.enableDrag` (gates drag handler callbacks). - Wire `interaction.enableZoom` / `enablePan` to OrbitControls. - Wire `interaction.highlightNeighbors` (computes neighbor set from selectedId, drives existing size-boost in Nodes). - Surface read-only `simBackend` pill in Physics section. - Remove dead settings: `visual.nodeMode` (sprite never implemented), `visual.linkWidth` (WebGL line thickness ignored), `filters.*` (replaced by graphStore.visibleEdgeCategories). Sim controls: - Reheat button on a themed in-stack panel (theme-aware Tailwind styling matching Legend instead of hardcoded inline styles). Info panel moved into the left PanelStack so it stacks with Legend instead of overlapping. Hover/focus dim mechanic: - Hover or right-click "Focus on node" produces an active set (driver + neighbors) and a dim alpha (0.2 hover, 0.05 focus, mirrors 2D). Non-active nodes/edges/arrows fade their color; non-active labels fade material opacity. Focus wins over hover. - Hover NodeLabel suppressed when persistent node labels are on. Bug fixes: - Left-click on a node opens info panel (no longer triggers ExplorerView's "Follow Concept" graph reload). - Right-click after add-adjacent works again — InstancedMesh bounding sphere is now nulled on data change so raycasts see new positions instead of the stale broad-phase cache. - Toggling edge/node labels off-then-on no longer renders white squares — texture-assignment effect now depends on `enabled` so remounted materials get their map reassigned. - V2 EdgeLabels stale-index crash on vocab-type filter toggle (subsumed by the rewrite to mesh-based labels).
This was referenced May 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 1 of ADR-702 — introduces a unified r3f-based 3D renderer (
ForceGraph3DV2) alongside the existingForceGraph3D(V1). V2 is selectable as a separate explorer route (force-3d-v2); V1 is unchanged.This PR commits to r3f + instanced GPU rendering + GPU-accelerated force simulation as the one path forward for force graphs, replacing the per-node three.js objects + per-edge Line2 geometry approach that bottlenecks V1 around a few hundred nodes.
What's in
instanceId, screen-space caret + hover labelbuildContextMenuItemsOut of scope
ForceGraph3D) stays. V2 must reach parity before V1 retires — planned follow-up PR.Ref
ADR-702 (Proposed) —
docs/architecture/user-interfaces/ADR-702-unified-graph-rendering-engine.mdMerge
Regular merge (
--merge) — commits each document a distinct milestone (M1–M5).