From 4e4485c7ba8e56cefd6bf1b94c112d8cb256ec45 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 18 May 2026 22:14:36 +0100 Subject: [PATCH] Fit Huginn terrain to graph bounds --- inspector/src/HuginnFieldCanvas.tsx | 94 ++++++++++++++++++++++------- inspector/src/main.tsx | 16 +++-- 2 files changed, 83 insertions(+), 27 deletions(-) diff --git a/inspector/src/HuginnFieldCanvas.tsx b/inspector/src/HuginnFieldCanvas.tsx index 0fb82e7..ef5e3bb 100644 --- a/inspector/src/HuginnFieldCanvas.tsx +++ b/inspector/src/HuginnFieldCanvas.tsx @@ -57,6 +57,12 @@ type GraphViewportTransform = { x: number; y: number; scale: number; + bounds: { + x: number; + y: number; + width: number; + height: number; + }; }; const gpuParticleComputeShader = /* wgsl */ ` @@ -81,9 +87,13 @@ struct SimUniforms { detail: f32, envelopeCount: f32, nodeGain: f32, - terrainOffsetX: f32, - terrainOffsetY: f32, - terrainScale: f32, + worldX: f32, + worldY: f32, + worldWidth: f32, + worldHeight: f32, + viewX: f32, + viewY: f32, + viewScale: f32, pad1: f32, }; @@ -235,9 +245,13 @@ struct SimUniforms { detail: f32, envelopeCount: f32, nodeGain: f32, - terrainOffsetX: f32, - terrainOffsetY: f32, - terrainScale: f32, + worldX: f32, + worldY: f32, + worldWidth: f32, + worldHeight: f32, + viewX: f32, + viewY: f32, + viewScale: f32, pad1: f32, }; @@ -266,11 +280,10 @@ fn vertexMain(@builtin(vertex_index) vertexIndex: u32, @builtin(instance_index) let tangent = vec2f(-direction.y, direction.x); let stretch = clamp(length(particle.velocity) / 92.0, 0.0, 2.1); let ellipse = direction * local.x * particle.size * (1.0 + stretch * 0.48) + tangent * local.y * particle.size * (0.74 + renderUniforms.detail * 0.035); - let center = vec2f(renderUniforms.width, renderUniforms.height) * 0.5; - let terrainScale = max(renderUniforms.terrainScale, 0.05); - let terrainOffset = vec2f(renderUniforms.terrainOffsetX, renderUniforms.terrainOffsetY); - let basePosition = (particle.position * vec2f(renderUniforms.width, renderUniforms.height) - center) * terrainScale + center + terrainOffset; - let pixelPosition = basePosition + ellipse * sqrt(terrainScale); + let world = vec2f(renderUniforms.worldX, renderUniforms.worldY) + particle.position * vec2f(renderUniforms.worldWidth, renderUniforms.worldHeight); + let viewScale = max(renderUniforms.viewScale, 0.05); + let basePosition = vec2f(renderUniforms.viewX, renderUniforms.viewY) + world * viewScale; + let pixelPosition = basePosition + ellipse * sqrt(viewScale); var out: VertexOut; out.position = vec4f((pixelPosition.x / renderUniforms.width) * 2.0 - 1.0, 1.0 - (pixelPosition.y / renderUniforms.height) * 2.0, 0.0, 1.0); out.color = particle.color * (1.0 - abs(particle.life - 0.5) * 0.44); @@ -569,7 +582,7 @@ async function startGpuParticleField(canvas: HTMLCanvasElement, sample: FieldSam particleBuffer.unmap(); const uniformBuffer = device.createBuffer({ - size: 16 * Float32Array.BYTES_PER_ELEMENT, + size: 20 * Float32Array.BYTES_PER_ELEMENT, usage: 64 | 8, }); const nodeEnvelopeBuffer = device.createBuffer({ @@ -581,11 +594,11 @@ async function startGpuParticleField(canvas: HTMLCanvasElement, sample: FieldSam count: 0, }; const transformState: { - base: GraphViewportTransform | null; + received: boolean; current: GraphViewportTransform; } = { - base: null, - current: { x: 0, y: 0, scale: 1 }, + received: false, + current: { x: 0, y: 0, scale: 1, bounds: { x: 0, y: 0, width: 1, height: 1 } }, }; const computeShader = device.createShaderModule({ code: gpuParticleComputeShader }); @@ -647,8 +660,15 @@ async function startGpuParticleField(canvas: HTMLCanvasElement, sample: FieldSam lastTime = time; detail += (targetDetail - detail) * Math.min(1, dt * 4.8); const activeParticleCount = Math.floor(PARTICLE_COUNT * Math.min(1, 0.34 + detail * 0.22)); - const baseTransform = transformState.base ?? transformState.current; - const terrainScale = Math.max(0.18, Math.min(16, transformState.current.scale / Math.max(0.001, baseTransform.scale))); + const graphTransform = transformState.received + ? transformState.current + : { + x: 0, + y: 0, + scale: 1, + bounds: { x: 0, y: 0, width: canvas.width, height: canvas.height }, + }; + const bounds = fitArtworkBounds(graphTransform.bounds); device.queue.writeBuffer(uniformBuffer, 0, new Float32Array([ time / 1000, dt, @@ -660,9 +680,13 @@ async function startGpuParticleField(canvas: HTMLCanvasElement, sample: FieldSam detail, nodeEnvelopeState.count, 1.12 + detail * 0.18, - transformState.current.x - baseTransform.x, - transformState.current.y - baseTransform.y, - terrainScale, + bounds.x, + bounds.y, + bounds.width, + bounds.height, + graphTransform.x, + graphTransform.y, + graphTransform.scale, 0, ])); @@ -806,7 +830,7 @@ function attachNodeEnvelopeListener( function attachViewportTransformListener( canvas: HTMLCanvasElement, - state: { base: GraphViewportTransform | null; current: GraphViewportTransform }, + state: { received: boolean; current: GraphViewportTransform }, ): () => void { const target = canvas.parentElement ?? canvas; const onTransform = (event: Event) => { @@ -822,13 +846,39 @@ function attachViewportTransformListener( x: detail.x, y: detail.y, scale: detail.scale, + bounds: isGraphBounds(detail.bounds) ? detail.bounds : state.current.bounds, }; - state.base ??= state.current; + state.received = true; }; target.addEventListener("epiphanygraph-viewport-transform", onTransform as EventListener); return () => target.removeEventListener("epiphanygraph-viewport-transform", onTransform as EventListener); } +function isGraphBounds(value: unknown): value is GraphViewportTransform["bounds"] { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as Partial; + return ( + typeof candidate.x === "number" && + typeof candidate.y === "number" && + typeof candidate.width === "number" && + typeof candidate.height === "number" + ); +} + +function fitArtworkBounds(bounds: GraphViewportTransform["bounds"]) { + const width = Math.max(1, bounds.width); + const height = Math.max(1, bounds.height); + const side = Math.max(width, height); + return { + x: bounds.x + width * 0.5 - side * 0.5, + y: bounds.y + height * 0.5 - side * 0.5, + width: side, + height: side, + }; +} + function resizeGpuCanvas(canvas: HTMLCanvasElement): void { const rect = canvas.getBoundingClientRect(); const scale = window.devicePixelRatio || 1; diff --git a/inspector/src/main.tsx b/inspector/src/main.tsx index 5a5dd1a..b42e5b4 100644 --- a/inspector/src/main.tsx +++ b/inspector/src/main.tsx @@ -1,4 +1,4 @@ -import { EpiphanyGraphViewer, type EpiphanyGraphEdge, type EpiphanyGraphNode, type EpiphanyGraphsState, type TerrainForceSample, type ViewerSelection } from "@epiphanygraph/epiphany-graph-viewer"; +import { EpiphanyGraphViewer, type EpiphanyGraphEdge, type EpiphanyGraphNode, type EpiphanyGraphsState, type TerrainForceContext, type TerrainForceSample, type ViewerSelection } from "@epiphanygraph/epiphany-graph-viewer"; import { createRoot } from "react-dom/client"; import { Component, useEffect, useMemo, useRef, useState, type DragEvent, type ReactNode } from "react"; @@ -441,11 +441,17 @@ function useHuginnTerrainForces(fieldUrl: string) { damping: 0.9, envelopeStrength: 0.03, emitNodeEnvelopes: true, - sample: (x: number, y: number): TerrainForceSample => { + sample: (x: number, y: number, context: TerrainForceContext): TerrainForceSample => { const texture = textureRef.current; + const bounds = context.bounds ?? { x: 0, y: 0, width: 1, height: 1 }; + const worldX = (x * context.viewportWidth - context.viewX) / Math.max(0.001, context.scale); + const worldY = (y * context.viewportHeight - context.viewY) / Math.max(0.001, context.scale); + const side = Math.max(1, Math.max(bounds.width, bounds.height)); + const uvX = (worldX - (bounds.x + bounds.width * 0.5 - side * 0.5)) / side; + const uvY = (worldY - (bounds.y + bounds.height * 0.5 - side * 0.5)) / side; if (!texture) { - const dx = x - 0.5; - const dy = y - 0.5; + const dx = uvX - 0.5; + const dy = uvY - 0.5; const radius = Math.max(0.001, Math.hypot(dx, dy)); return { flowX: -dy / radius * 0.35, @@ -454,7 +460,7 @@ function useHuginnTerrainForces(fieldUrl: string) { curvature: 0.25, }; } - return sampleTerrainTexture(texture, x, y); + return sampleTerrainTexture(texture, uvX, uvY); }, }), []); }