diff --git a/CLAUDE.md b/CLAUDE.md index 6e43211..a631b3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,7 @@ This is the **ktsu ImGui Suite**, a collection of .NET libraries for building De - **ImGui.Color** (`ktsu.ImGui.Color`) - Bridge between `ktsu.Semantics.Color` and ImGui. Colors are held as the semantic `Color` (linear) and `Srgb` types and converted only at the ImGui seam: `ColorImGuiExtensions` (`ToImColor`/`FromImColor`, `ToImGuiVector4`, `ToImGuiU32`) and `SrgbImGuiExtensions` (`Srgb` → `ImColor`/`ImGuiVector4`/`ImU32`, packed directly with no linear round-trip). The `ImColor` and `Srgb` `ToImGuiU32` apply the global style alpha like `ImGui.GetColorU32`; the linear `Color.ToImGuiU32` is a pure pack matching `ColorConvertFloat4ToU32`. `ImColor` extension operations: adjustments (lighten/darken, saturate/desaturate, hue offset, grayscale, invert, alpha), analysis (relative luminance, contrast ratio, perceptual distance), and contrast heuristics (`MostReadableTextColor`, `AdjustForSufficientContrast`). All color math delegates to `ktsu.Semantics.Color`. (There is no `ImColor` factory class — construct via `Color`/`Srgb` and convert.) - **ImGui.Styler** (`ktsu.ImGui.Styler`) - Theming system with 50+ built-in themes, scoped styling, Button.Alignment, Text.Color semantic colors, Indent utilities, Alignment helpers, theme-aware color palette (`Palette`, e.g. `Palette.Basic.Red`, `Palette.Semantic.Error`), and interactive theme browser. Color construction and manipulation live in `ImGui.Color`. - **NodeGraph** (`ktsu.NodeGraph`) - UI-agnostic attribute-based node graph metadata: `[Node]`, `[InputPin]`, `[OutputPin]`, `[NodeExecute]`, `[NodeBehavior]`, pin type utilities -- **ImGui.NodeEditor** (`ktsu.ImGui.NodeEditor`) - ImNodes-based visual node editor with `NodeEditorEngine`, `AttributeBasedNodeFactory`, physics-based layout, `NodeEditorRenderer`, `NodeEditorInputHandler`. ImNodes has no zoom of its own, so `NodeEditorRenderer.Zoom` supplies one and `FitToView` centres a graph and picks the zoom it fits at; the engine's positions and sizes stay at their own scale throughout, since that is the space the layout's lengths are measured in +- **ImGui.NodeEditor** (`ktsu.ImGui.NodeEditor`) - ImNodes-based visual node editor with `NodeEditorEngine`, `AttributeBasedNodeFactory`, physics-based layout, `NodeEditorRenderer`, `NodeEditorInputHandler`. `PhysicsSettingsPanel.Draw(ref PhysicsSettings)` draws every layout setting grouped by force and captioned, and `DrawDiagnostics(engine)` the live energy and settled state, so a consuming application gets the whole tuning surface rather than reimplementing a subset of it. ImNodes has no zoom of its own, so `NodeEditorRenderer.Zoom` supplies one and `FitToView` centres a graph and picks the zoom it fits at; the engine's positions and sizes stay at their own scale throughout, since that is the space the layout's lengths are measured in - **ImGui.Markdown** (`ktsu.ImGui.Markdown`) - CommonMark markdown renderer built on Markdig (pipe tables, task lists, autolinks), layered on `ImGui.Color` only, with no dependency on `ImGui.App`. Static `ImGuiMarkdown.Render(string, MarkdownConfig?)` parses with an internal source-keyed cache; `MarkdownDocument` parses once for hot render paths. `MarkdownConfig` exposes `FontResolver`, `OnLinkClicked`, `ImageResolver`, `HeadingScales`, `WrapWidth`, `ListIndentPixels`, `ParagraphSpacingPixels`, and `LinkColor`. Heading sizes derive from the live font size, so DPI and `ImGuiApp.GlobalScale` are respected automatically. Bold/italic use real glyphs when the host app registers named font variants via `FontResolver`, otherwise faux styling (faux-bold double-draw, faux-italic renders upright). Fenced and indented code blocks go to `MarkdownConfig.CodeBlockRenderer` (`Action?` — the fence's info string and the block text) when one is supplied, which takes over drawing *and* reserving the block's layout space; `ImGui.SyntaxHighlighting` plugs into it, and neither library references the other. v1 has no built-in code-block syntax highlighting, no async remote image download, and renders HTML as escaped text. - **SyntaxHighlighting** (`ktsu.SyntaxHighlighting`) - Renderer-agnostic tokenizing: no ImGui, no graphics API, no third-party parser, so it can move to its own repository unchanged. `SyntaxHighlighter.Highlight(code, language, tabWidth)` returns the classified `HighlightedLine`/`HighlightedToken` runs; `SyntaxHighlighter.HighlightCached` goes through a bounded cache keyed by source, language and tab width; `HighlightedCode` tokenizes once for hot render paths. Languages are data (`LanguageDefinition`: line/block comment, string, keyword, type, constant, operator, identifier and embedded-language rules) held in `LanguageRegistry`, which resolves names and aliases case-insensitively and falls back to plain text for unknown names rather than throwing. Fifteen built-ins in `BuiltInLanguages`: text, csharp, c, cpp, javascript, typescript, python, json, yaml, xml, html, css, sql, shell, lua. Two tokenizers back them — the general `CodeTokenizer`, and `MarkupTokenizer` for definitions with `IsMarkup` (XML/HTML), which classify structurally rather than by keyword. `SyntaxTheme` holds one `ktsu.Semantics.Color.Color` per `TokenKind`, with `Dark`/`Light` built in and `Background`/`Plain`/`LineNumber` left unset for the host to fill. Comments and strings are searched for an embedded language; see [Embedded languages](#embedded-languages) below. Highlighting is lexical. - **ImGui.SyntaxHighlighting** (`ktsu.ImGui.SyntaxHighlighting`) - The Dear ImGui drawing layer over `ktsu.SyntaxHighlighting`, layered on `ImGui.Color` only, with no dependency on `ImGui.App`. Static `ImGuiSyntaxHighlighting.Render(code, language, SyntaxHighlightConfig?)` tokenizes through the shared cache and draws; `Render(HighlightedCode, config)` draws pre-tokenized code, and `HighlightedCodeExtensions` re-adds `code.Render(config)` as an extension since the tokenized type itself knows nothing about ImGui. `Highlight` forwards to `SyntaxHighlighter.Highlight`. Leaving `SyntaxHighlightConfig.Theme` null picks between `SyntaxTheme.Dark`/`Light` per frame from the window background's luminance, and unset `Background`/`Plain`/`LineNumber` come from `FrameBg`/`Text`/`TextDisabled`. Code is never wrapped, and there is no scrolling, selection or editing. `ImGui.Markdown`'s `CodeBlockRenderer` plugs into this, and neither library references the other. diff --git a/ImGui.NodeEditor/ImGui.NodeEditor.csproj b/ImGui.NodeEditor/ImGui.NodeEditor.csproj index 50f3345..e60114a 100644 --- a/ImGui.NodeEditor/ImGui.NodeEditor.csproj +++ b/ImGui.NodeEditor/ImGui.NodeEditor.csproj @@ -20,5 +20,6 @@ + diff --git a/ImGui.NodeEditor/PhysicsSettingsPanel.cs b/ImGui.NodeEditor/PhysicsSettingsPanel.cs new file mode 100644 index 0000000..16f6ea0 --- /dev/null +++ b/ImGui.NodeEditor/PhysicsSettingsPanel.cs @@ -0,0 +1,362 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImGui.NodeEditor; + +using System.Globalization; + +using Hexa.NET.ImGui; + +using ktsu.ForceDirectedLayout; +using ktsu.ImGui.Probes; + +/// +/// Draws every knob the layout simulation has, so a graph can be tuned while it is on screen. +/// +/// +/// The forces interact, and none of them can be judged on its own: raising repulsion changes what +/// the spring's rest length means, and levelling links only works in the room repulsion made. So the +/// panel exposes the whole of rather than a chosen subset, grouped by +/// the force each setting belongs to and captioned with what moving it does. A setting that is not +/// here is a setting nobody can reach without recompiling. +/// +/// Every control marks itself with , so a UI test can address it by the +/// label the user sees. +/// +/// +public static class PhysicsSettingsPanel +{ + /// The value every "Reset" restores, and what the sliders are ranged around. + private static readonly PhysicsSettings Defaults = new(); + + /// + /// Draws the whole panel. + /// + /// The settings to edit; replaced when the user changes anything. + /// True when was changed by this frame's input. + public static bool Draw(ref PhysicsSettings settings) + { + Ensure.NotNull(settings); + + bool changed = false; + PhysicsSettings working = settings; + + bool enabled = working.Enabled; + if (Checkbox("Run simulation", ref enabled)) + { + working = working with { Enabled = enabled }; + changed = true; + } + + ImGui.SameLine(); + if (Button("Reset all")) + { + // The running flag is the user's, not the layout's, so a reset of the tuning leaves it be. + working = Defaults with { Enabled = working.Enabled }; + changed = true; + } + + changed |= DrawRepulsion(ref working); + changed |= DrawSprings(ref working); + changed |= DrawLinkShaping(ref working); + changed |= DrawGravity(ref working); + changed |= DrawOverlap(ref working); + changed |= DrawIntegration(ref working); + + if (changed) + { + settings = working; + } + + return changed; + } + + /// + /// Draws what the simulation is doing right now, which is what says whether a setting helped. + /// + /// The engine being tuned. + /// + /// Energy is the honest read on a tuning change: a graph that will not settle shows it here long + /// before the eye catches the drift, and a graph that settles instantly was probably damped into + /// stillness before it finished arranging itself. + /// + public static void DrawDiagnostics(NodeEditorEngine engine) + { + Ensure.NotNull(engine); + + (int substeps, float substepDelta) = engine.LastPhysicsStepInfo; + + Text("Energy", engine.TotalSystemEnergy.ToString("F1", CultureInfo.CurrentCulture)); + Text("Settled", engine.IsStable ? "yes" : "no"); + Text("Substeps", substeps.ToString(CultureInfo.CurrentCulture)); + Text("Substep", $"{substepDelta * 1000f:F2} ms"); + Text("Bodies", $"{engine.Nodes.Count} nodes, {engine.Links.Count} links"); + } + + private static bool DrawRepulsion(ref PhysicsSettings settings) + { + if (!Header("Repulsion")) + { + return false; + } + + bool changed = false; + double repulsion = settings.RepulsionStrength; + if (Slider("Repulsion strength", ref repulsion, 0.0, 50_000_000.0, "%.0f", + "Pushes every pair of nodes apart, by the inverse square of their distance. This is what makes the room the other forces arrange things in; with none, a graph collapses onto itself.")) + { + settings = settings with { RepulsionStrength = repulsion }; + changed = true; + } + + double minDistance = settings.MinRepulsionDistance; + if (Slider("Minimum distance", ref minDistance, 1.0, 200.0, "%.0f px", + "The distance repulsion stops getting stronger at. Without a floor, two nodes that nearly touch would be flung apart.")) + { + settings = settings with { MinRepulsionDistance = minDistance }; + changed = true; + } + + return changed; + } + + private static bool DrawSprings(ref PhysicsSettings settings) + { + if (!Header("Link springs")) + { + return false; + } + + bool changed = false; + double spring = settings.LinkSpringStrength; + if (Slider("Spring strength", ref spring, 0.0, 3.0, "%.2f", + "How hard a link pulls its two ends toward the rest length. Raise it to tighten clusters, lower it to let repulsion spread them.")) + { + settings = settings with { LinkSpringStrength = spring }; + changed = true; + } + + double rest = settings.RestLinkLength; + if (Slider("Rest length", ref rest, 50.0, 600.0, "%.0f px", + "The distance a link is happy at, measured between the pins it actually joins. This sets the horizontal step between one level of the graph and the next.")) + { + settings = settings with { RestLinkLength = rest }; + changed = true; + } + + double bias = settings.DirectionalBias; + if (Slider("Left-to-right bias", ref bias, 0.0, 3.0, "%.2f", + "Pushes each link's source left of its target. A link running backwards is reordered, and the pair is allowed past each other vertically while it happens.")) + { + settings = settings with { DirectionalBias = bias }; + changed = true; + } + + return changed; + } + + private static bool DrawLinkShaping(ref PhysicsSettings settings) + { + if (!Header("Link shaping")) + { + return false; + } + + bool changed = false; + double flattening = settings.LinkFlatteningStrength; + if (Slider("Flattening", ref flattening, 0.0, 3.0, "%.2f", + "Levels each link's two ends, and splays them apart horizontally when the drawn curve would otherwise double back behind its own nodes and disappear.")) + { + settings = settings with { LinkFlatteningStrength = flattening }; + changed = true; + } + + double margin = settings.LinkFlatteningMargin; + if (Slider("Flattening margin", ref margin, 0.0, 200.0, "%.0f px", + "Extra horizontal room demanded on top of the clearance the curve geometry needs. Raise it if links still graze the nodes they leave.")) + { + settings = settings with { LinkFlatteningMargin = margin }; + changed = true; + } + + double untwist = settings.LinkUntwistStrength; + if (Slider("Untwisting", ref untwist, 0.0, 1.0, "%.3f", + "Swaps two links that meet at one node into the order of the pins they arrive at, so they stop crossing. Stronger settings untangle more and drive more nodes across links they are not part of.")) + { + settings = settings with { LinkUntwistStrength = untwist }; + changed = true; + } + + return changed; + } + + private static bool DrawGravity(ref PhysicsSettings settings) + { + if (!Header("Gravity")) + { + return false; + } + + bool changed = false; + double gravity = settings.GravityStrength; + if (Slider("Gravity strength", ref gravity, 0.0, 400.0, "%.0f", + "Pulls every node toward the gravity target, which is what stops repulsion pushing a graph apart for ever.")) + { + settings = settings with { GravityStrength = gravity }; + changed = true; + } + + double anchor = settings.OriginAnchorWeight; + if (Slider("Origin anchor", ref anchor, 0.0, 1.0, "%.2f", + "Where that target sits: 0 is the graph's own centre, so it may drift; 1 is the fixed world origin, so it stays put.")) + { + settings = settings with { OriginAnchorWeight = anchor }; + changed = true; + } + + return changed; + } + + private static bool DrawOverlap(ref PhysicsSettings settings) + { + if (!Header("Overlap")) + { + return false; + } + + bool changed = false; + double margin = settings.OverlapMargin; + if (Slider("Clearance", ref margin, 0.0, 200.0, "%.0f px", + "The gap kept between two node rectangles. At zero the pass is off and the forces alone decide, which leaves nodes drawn over each other.")) + { + settings = settings with { OverlapMargin = margin }; + changed = true; + } + + double correction = settings.MaxOverlapCorrection; + if (Slider("Maximum correction", ref correction, 1.0, 200.0, "%.0f px", + "How far a pair may be pushed apart in one substep. Lower values make a deep overlap slide apart over several frames rather than snap.")) + { + settings = settings with { MaxOverlapCorrection = correction }; + changed = true; + } + + return changed; + } + + private static bool DrawIntegration(ref PhysicsSettings settings) + { + if (!Header("Motion and limits")) + { + return false; + } + + bool changed = false; + double damping = settings.DampingFactor; + if (Slider("Damping", ref damping, 0.01, 0.99, "%.2f", + "The fraction of a node's speed kept each second. Low values settle quickly but can stop a graph before it has finished arranging itself.")) + { + settings = settings with { DampingFactor = damping }; + changed = true; + } + + double maxForce = settings.MaxForce; + if (Slider("Maximum force", ref maxForce, 100.0, 50_000.0, "%.0f", + "The cap on the total force one node may feel, which is what keeps two nearly coincident nodes from exploding.")) + { + settings = settings with { MaxForce = maxForce }; + changed = true; + } + + double maxVelocity = settings.MaxVelocity; + if (Slider("Maximum speed", ref maxVelocity, 5.0, 1000.0, "%.0f px/s", + "The cap on how fast a node may travel. It bounds how long a graph takes to settle, since a node cannot reach its place faster than this.")) + { + settings = settings with { MaxVelocity = maxVelocity }; + changed = true; + } + + double hz = settings.TargetPhysicsHz; + if (Slider("Substep rate", ref hz, 30.0, 480.0, "%.0f Hz", + "How finely each frame is subdivided, independent of the frame rate. Higher is steadier and costs more.")) + { + settings = settings with { TargetPhysicsHz = hz }; + changed = true; + } + + double stability = settings.StabilityThreshold; + if (Slider("Settled below", ref stability, 0.0, 100.0, "%.1f", + "The total energy under which the graph is reported settled. It changes what is reported, not how anything moves.")) + { + settings = settings with { StabilityThreshold = stability }; + changed = true; + } + + return changed; + } + + /// + /// Submits one labelled slider over a double, with its explanation on hover. + /// + /// + /// ImGui edits floats, and every one of these settings is a double, so each is narrowed for the + /// drag and widened back. The write only happens when the slider reports a change, so a setting + /// the user never touches keeps its full precision rather than being rounded by being looked at. + /// + private static bool Slider(string label, ref double value, double min, double max, string format, string help) + { + float editing = (float)value; + bool changed = ImGui.SliderFloat(label, ref editing, (float)min, (float)max, format); + ImGuiProbes.MarkItem(label); + Explain(help); + + if (!changed) + { + return false; + } + + value = editing; + return true; + } + + private static bool Checkbox(string label, ref bool value) + { + bool changed = ImGui.Checkbox(label, ref value); + ImGuiProbes.MarkItem(label); + return changed; + } + + private static bool Button(string label) + { + bool clicked = ImGui.Button(label); + ImGuiProbes.MarkItem(label); + return clicked; + } + + private static bool Header(string label) + { + bool open = ImGui.CollapsingHeader(label); + ImGuiProbes.MarkItem(label); + return open; + } + + private static void Text(string label, string value) + { + ImGui.TextUnformatted($"{label}: {value}"); + ImGuiProbes.MarkItem(label); + } + + /// Attaches the previous item's explanation, wrapped to a readable column. + private static void Explain(string help) + { + if (!ImGui.IsItemHovered()) + { + return; + } + + ImGui.BeginTooltip(); + ImGui.PushTextWrapPos(ImGui.GetFontSize() * 28f); + ImGui.TextUnformatted(help); + ImGui.PopTextWrapPos(); + ImGui.EndTooltip(); + } +} diff --git a/ImGui.NodeEditor/README.md b/ImGui.NodeEditor/README.md index 3eb484b..eecfffd 100644 --- a/ImGui.NodeEditor/README.md +++ b/ImGui.NodeEditor/README.md @@ -8,6 +8,7 @@ ImGui.NodeEditor is a visual node editor built on ImNodes, with the graph itself ## Features - **Separation of concerns**: business logic (`NodeEditorEngine`), rendering (`NodeEditorRenderer`), and input (`NodeEditorInputHandler`) are separate objects, so the graph can be built and tested without a renderer +- **Tuning panel**: `PhysicsSettingsPanel` draws every layout setting, grouped and captioned, so a graph can be tuned while it is on screen - **Attribute-based nodes**: `AttributeBasedNodeFactory` reads `ktsu.NodeGraph` attributes off a type — or every decorated type in an assembly — and creates nodes with the right pins - **Physics is opt-in**: the simulation does nothing until `PhysicsSettings.Enabled` is set, so a host that positions nodes itself pays nothing for it - **Type-aware connections**: `TryCreateLink` returns a result with a message rather than throwing, and pin compatibility comes from the same rules the metadata declares @@ -165,6 +166,29 @@ The graph and its physics. No ImGui calls. | `RenderDebugOverlays(...)` | `void` | Force and stability overlays | | `CurrentlyDraggedNodes` | `IReadOnlySet` | Nodes the user is dragging this frame | +### `PhysicsSettingsPanel` + +| Name | Return Type | Description | +| ---- | ----------- | ----------- | +| `Draw(ref PhysicsSettings)` | `bool` | Draws every tunable the simulation has, grouped by force and captioned; true when the user changed one | +| `DrawDiagnostics(NodeEditorEngine)` | `void` | Energy, whether it has settled, substep count and rate | + +The forces interact, so none can be judged alone: raising repulsion changes what the spring's rest +length means, and levelling links only works in the room repulsion made. The panel therefore exposes +the whole of `PhysicsSettings` rather than a chosen subset — a setting that is not on it is one +nobody can reach without recompiling. Every control marks itself with `ktsu.ImGui.Probes`, so a UI +test can address it by the label the user sees. + +```csharp +PhysicsSettings settings = engine.PhysicsSettings; +if (PhysicsSettingsPanel.Draw(ref settings)) +{ + engine.UpdatePhysicsSettings(settings); +} + +PhysicsSettingsPanel.DrawDiagnostics(engine); +``` + ### `NodeEditorInputHandler` `ProcessInput()` returns `InputEvents`, holding `LinkCreationRequests` (`LinkCreationRequest(FromPinId, ToPinId)`) and `LinkDeletionRequests`. diff --git a/examples/ImGuiAppDemo/Demos/CleanImNodesDemo.cs b/examples/ImGuiAppDemo/Demos/CleanImNodesDemo.cs index 9d2c574..6b7611a 100644 --- a/examples/ImGuiAppDemo/Demos/CleanImNodesDemo.cs +++ b/examples/ImGuiAppDemo/Demos/CleanImNodesDemo.cs @@ -29,8 +29,6 @@ internal sealed class CleanImNodesDemo : IDemoTab private bool showDebugVisualization; private string lastActionMessage = ""; private Vector4 lastActionColor = new(1.0f, 1.0f, 1.0f, 1.0f); - private int lastSubstepCount; - private float lastSubstepDeltaTime; public CleanImNodesDemo() { @@ -47,9 +45,6 @@ public void Update(float deltaTime) // Update physics simulation engine.UpdatePhysics(deltaTime); - - // Store debug info - (lastSubstepCount, lastSubstepDeltaTime) = engine.LastPhysicsStepInfo; } public void Render() @@ -289,115 +284,25 @@ private void RenderDebugInformation() } } + /// + /// Draws the layout tuning, which the node editor library supplies whole. + /// + /// + /// The panel covers every setting the simulation has and captions each one, so this demo shows the + /// same controls a consuming application gets rather than a copy of them that drifts. + /// private void RenderPhysicsControls() { - PhysicsSettings currentSettings = engine.PhysicsSettings; - bool settingsChanged = false; - - // Physics enabled checkbox - bool enabled = currentSettings.Enabled; - if (DemoProbe.Checkbox("Enable Physics", ref enabled)) - { - currentSettings = currentSettings with { Enabled = enabled }; - settingsChanged = true; - } - - if (!enabled) + PhysicsSettings settings = engine.PhysicsSettings; + if (PhysicsSettingsPanel.Draw(ref settings)) { - ImGui.BeginDisabled(); - } - - // Repulsion settings - if (DemoProbe.Header("Repulsion Forces")) - { - float repulsionStrength = (float)currentSettings.RepulsionStrength; - if (DemoProbe.SliderFloat("Repulsion Strength (N)", ref repulsionStrength, 100_000.0f, 50_000_000.0f)) - { - currentSettings = currentSettings with { RepulsionStrength = repulsionStrength }; - settingsChanged = true; - } - - float minRepulsionDistance = (float)currentSettings.MinRepulsionDistance; - if (DemoProbe.SliderFloat("Min Repulsion Clamp (px)", ref minRepulsionDistance, 10.0f, 200.0f)) - { - currentSettings = currentSettings with { MinRepulsionDistance = minRepulsionDistance }; - settingsChanged = true; - } - } - - // Link spring settings - if (DemoProbe.Header("Link Springs")) - { - float linkSpringStrength = (float)currentSettings.LinkSpringStrength; - if (DemoProbe.SliderFloat("Spring Strength (dimensionless)", ref linkSpringStrength, 0.1f, 2.0f)) - { - currentSettings = currentSettings with { LinkSpringStrength = linkSpringStrength }; - settingsChanged = true; - } - - float restLinkLength = (float)currentSettings.RestLinkLength; - if (DemoProbe.SliderFloat("Rest Length (m)", ref restLinkLength, 100.0f, 400.0f)) - { - currentSettings = currentSettings with { RestLinkLength = restLinkLength }; - settingsChanged = true; - } - - float directionalBias = (float)currentSettings.DirectionalBias; - if (DemoProbe.SliderFloat("Directional Bias (L→R)", ref directionalBias, 0.0f, 2.0f)) - { - currentSettings = currentSettings with { DirectionalBias = directionalBias }; - settingsChanged = true; - } - - float linkFlatteningStrength = (float)currentSettings.LinkFlatteningStrength; - if (DemoProbe.SliderFloat("Link Flattening", ref linkFlatteningStrength, 0.0f, 2.0f)) - { - currentSettings = currentSettings with { LinkFlatteningStrength = linkFlatteningStrength }; - settingsChanged = true; - } - - float linkFlatteningMargin = (float)currentSettings.LinkFlatteningMargin; - if (DemoProbe.SliderFloat("Link Flattening Margin (px)", ref linkFlatteningMargin, 0.0f, 200.0f)) - { - currentSettings = currentSettings with { LinkFlatteningMargin = linkFlatteningMargin }; - settingsChanged = true; - } - - float linkUntwistStrength = (float)currentSettings.LinkUntwistStrength; - if (DemoProbe.SliderFloat("Link Untwisting", ref linkUntwistStrength, 0.0f, 1.0f)) - { - currentSettings = currentSettings with { LinkUntwistStrength = linkUntwistStrength }; - settingsChanged = true; - } + engine.UpdatePhysicsSettings(settings); } - // Gravity settings - if (DemoProbe.Header("Gravity")) - { - float gravityStrength = (float)currentSettings.GravityStrength; - if (DemoProbe.SliderFloat("Gravity Strength (N)", ref gravityStrength, 0.0f, 200.0f)) - { - currentSettings = currentSettings with { GravityStrength = gravityStrength }; - settingsChanged = true; - } - - float originAnchorWeight = (float)currentSettings.OriginAnchorWeight; - if (DemoProbe.SliderFloat("Origin Anchor Weight", ref originAnchorWeight, 0.0f, 1.0f)) - { - currentSettings = currentSettings with { OriginAnchorWeight = originAnchorWeight }; - settingsChanged = true; - } - } - - // Damping and limits - (currentSettings, settingsChanged) = RenderDampingAndLimitsControls(currentSettings, settingsChanged, enabled); - - // Quick presets if (DemoProbe.Button("Gentle Physics")) { - currentSettings = new PhysicsSettings + engine.UpdatePhysicsSettings(settings with { - Enabled = true, RepulsionStrength = 2_000_000.0, LinkSpringStrength = 0.3, DirectionalBias = 0.3, @@ -406,21 +311,17 @@ private void RenderPhysicsControls() GravityStrength = 20.0, OriginAnchorWeight = 0.2, DampingFactor = 0.95, - MinRepulsionDistance = 50.0, RestLinkLength = 250.0, MaxForce = 3000.0, MaxVelocity = 100.0, - TargetPhysicsHz = 120.0, - }; - settingsChanged = true; + }); } ImGui.SameLine(); if (DemoProbe.Button("Strong Physics")) { - currentSettings = new PhysicsSettings + engine.UpdatePhysicsSettings(settings with { - Enabled = true, RepulsionStrength = 10_000_000.0, LinkSpringStrength = 1.0, DirectionalBias = 0.8, @@ -433,83 +334,11 @@ private void RenderPhysicsControls() RestLinkLength = 200.0, MaxForce = 10000.0, MaxVelocity = 300.0, - TargetPhysicsHz = 120.0, - }; - settingsChanged = true; - } - - if (!enabled) - { - ImGui.EndDisabled(); - } - - if (settingsChanged) - { - engine.UpdatePhysicsSettings(currentSettings); + }); } - } - - private (PhysicsSettings Settings, bool Changed) RenderDampingAndLimitsControls(PhysicsSettings currentSettings, bool settingsChanged, bool enabled) - { - if (DemoProbe.Header("Damping & Limits")) - { - float dampingFactor = (float)currentSettings.DampingFactor; - if (DemoProbe.SliderFloat("Damping Factor (dimensionless)", ref dampingFactor, 0.1f, 0.99f)) - { - currentSettings = currentSettings with { DampingFactor = dampingFactor }; - settingsChanged = true; - } - - float maxForce = (float)currentSettings.MaxForce; - if (DemoProbe.SliderFloat("Max Force (N)", ref maxForce, 100.0f, 50000.0f)) - { - currentSettings = currentSettings with { MaxForce = maxForce }; - settingsChanged = true; - } - float maxVelocity = (float)currentSettings.MaxVelocity; - if (DemoProbe.SliderFloat("Max Velocity (m/s)", ref maxVelocity, 5.0f, 500.0f)) - { - currentSettings = currentSettings with { MaxVelocity = maxVelocity }; - settingsChanged = true; - } - - float targetPhysicsHz = (float)currentSettings.TargetPhysicsHz; - if (DemoProbe.SliderFloat("Target Physics Hz", ref targetPhysicsHz, 60.0f, 240.0f)) - { - currentSettings = currentSettings with { TargetPhysicsHz = targetPhysicsHz }; - settingsChanged = true; - } - - // Show actual physics timing info - if (enabled && lastSubstepCount > 0) - { - ImGui.Separator(); - ImGui.Text($"Actual Substeps: {lastSubstepCount}"); - ImGui.Text($"Substep Δt: {lastSubstepDeltaTime * 1000.0f:F2}ms"); - - float effectiveHz = 1.0f / lastSubstepDeltaTime; - ImGui.Text($"Effective Hz: {effectiveHz:F1}"); - - // Visual indicator of physics quality - bool targetAchieved = effectiveHz >= currentSettings.TargetPhysicsHz; - Vector4 qualityColor = targetAchieved ? - new Vector4(0.0f, 1.0f, 0.0f, 1.0f) : // Green - target achieved - new Vector4(1.0f, 0.5f, 0.0f, 1.0f); // Orange - below target - - ImGui.TextColored(qualityColor, targetAchieved ? - "✓ Target achieved" : "⚠ Below target"); - - // Stability detection - ImGui.Text($"System Energy: {engine.TotalSystemEnergy:F2}"); - if (engine.IsStable) - { - ImGui.TextColored(new Vector4(0.0f, 1.0f, 0.0f, 1.0f), "Layout stable"); - } - } - } - - return (currentSettings, settingsChanged); + ImGui.Separator(); + PhysicsSettingsPanel.DrawDiagnostics(engine); } private void RegisterNodeTypes() diff --git a/tests/ImGuiAppDemo.UITests/AppDemoUITests.cs b/tests/ImGuiAppDemo.UITests/AppDemoUITests.cs index b5a31e0..ac7fbf1 100644 --- a/tests/ImGuiAppDemo.UITests/AppDemoUITests.cs +++ b/tests/ImGuiAppDemo.UITests/AppDemoUITests.cs @@ -331,7 +331,7 @@ public void CleanImNodes_PhysicsControlsRespond() { OpenTab(CleanImNodesTab); - foreach (string control in new[] { "Enable Physics", "Gentle Physics", "Strong Physics" }) + foreach (string control in new[] { "Run simulation", "Reset all", "Gentle Physics", "Strong Physics" }) { Assert.IsTrue(IsVisible(control), $"The clean ImNodes tab is missing '{control}'."); } @@ -345,27 +345,60 @@ public void CleanImNodes_PhysicsControlsRespond() } /// - /// The link-flattening sliders live under a collapsed header inside a panel that is disabled until - /// physics is on, so reaching them takes both a toggle and an expand. + /// Every tunable the simulation has is on this panel, grouped under collapsing headers by the + /// force it belongs to, so reaching one takes an expand first. /// + /// + /// The panel itself comes from ktsu.ImGui.NodeEditor rather than this demo, so what this covers is + /// that a consuming application gets working controls, not just that the demo drew some. + /// [TestMethod] - public void CleanImNodes_LinkShapingSlidersRespond() + public void CleanImNodes_EverySettingGroupOffersItsSliders() { OpenTab(CleanImNodesTab); - harness.Click("Enable Physics"); - harness.Step(2); - harness.Click("Link Springs"); - harness.Step(2); - - foreach (string slider in new[] { "Link Flattening", "Link Flattening Margin (px)", "Link Untwisting" }) + (string Header, string[] Sliders)[] groups = + [ + ("Repulsion", ["Repulsion strength", "Minimum distance"]), + ("Link springs", ["Spring strength", "Rest length", "Left-to-right bias"]), + ("Link shaping", ["Flattening", "Flattening margin", "Untwisting"]), + ("Gravity", ["Gravity strength", "Origin anchor"]), + ("Overlap", ["Clearance", "Maximum correction"]), + ("Motion and limits", ["Damping", "Maximum force", "Maximum speed", "Substep rate", "Settled below"]), + ]; + + foreach ((string header, string[] sliders) in groups) { - Assert.IsTrue(IsVisible(slider), $"Expanding Link Springs should reveal '{slider}'."); - DragSliderTrack(slider); + Assert.IsTrue(IsVisible(header), $"The tuning panel is missing its '{header}' group."); + harness.Click(header); + harness.Step(2); + + foreach (string slider in sliders) + { + Assert.IsTrue(IsVisible(slider), $"Expanding '{header}' should reveal '{slider}'."); + DragSliderTrack(slider); + harness.Step(2); + } + + // Collapse it again, so the next group's controls are not pushed below the fold. + harness.Click(header); harness.Step(2); } + } - Assert.IsTrue(IsVisible("Link Flattening"), "The link-shaping sliders should survive being dragged."); + /// + /// Tests that the panel reports what the simulation is doing, which is what says whether a change + /// to a setting helped. + /// + [TestMethod] + public void CleanImNodes_ReportsWhatTheSimulationIsDoing() + { + OpenTab(CleanImNodesTab); + + foreach (string readout in new[] { "Energy", "Settled", "Substeps", "Substep", "Bodies" }) + { + Assert.IsTrue(IsVisible(readout), $"The tuning panel is missing its '{readout}' readout."); + } } ///