diff --git a/CLAUDE.md b/CLAUDE.md index 8172d7d..f643649 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 -- **ForceDirectedLayout** (`ktsu.ForceDirectedLayout`) - Renderer-agnostic graph layout simulation, with no UI dependency and no runtime package dependencies. Bodies repel across the clear space between their bounding boxes (not between their centres — see [Layout benchmarking](#layout-benchmarking)), edges pull like springs between the pins they actually attach at, gravity holds the graph together, edges are pulled towards horizontal, and an overlap pass separates any boxes left drawn over one another. Three surfaces over one `LayoutCore`: a generic facade over your own types, an id-based `ForceLayout` for bulk POD submission, and the flat core. Also published as a Native AOT shared library with a C ABI. `ImGui.NodeEditor` is one consumer. +- **ForceDirectedLayout** (`ktsu.ForceDirectedLayout`) - Renderer-agnostic graph layout simulation, with no UI dependency and no runtime package dependencies. Bodies repel across the clear space between their bounding boxes (not between their centres — see [Layout benchmarking](#layout-benchmarking)), edges pull like springs between the pins they actually attach at, gravity holds the graph together, edges are pulled towards horizontal, an overlap pass separates any boxes left drawn over one another, and a recentring pass slides the whole arrangement so its drawn box sits on the world origin (see [Placement is not cohesion](#placement-is-not-cohesion)). Three surfaces over one `LayoutCore`: a generic facade over your own types, an id-based `ForceLayout` for bulk POD submission, and the flat core. Also published as a Native AOT shared library with a C ABI. `ImGui.NodeEditor` is one consumer. - **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. Hovering is answered by the renderer: `HighlightLinksOnNodeHover` (on) colours the links meeting the hovered node, `HighlightDownstreamOnNodeHover` (off) also colours everything that node's value reaches, and `DrawHoveredLinkOnTop` (on) redraws the hovered link over the nodes ImNodes drew on top of it. See [Hover highlighting](#hover-highlighting) below. How many links a pin accepts is the pin's own business: `Pin.AllowsMultipleConnections` defaults to many for an output and one for an input, `[InputPin(AllowMultipleConnections = true)]` / `[OutputPin(AllowMultipleConnections = false)]` override it through the factory, and `NodeEditorEngine.SetPinAllowsMultipleConnections` sets it directly. `GetOutgoingLinks`, `GetIncomingLinks`, `GetDownstream` and `GetUpstream` walk the graph - **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. @@ -615,6 +615,43 @@ Four things to know before changing any of it: unobservable because flattening decides that geometry outright. Their fixtures now name every input they rely on. +### Placement is not cohesion + +Gravity holds a graph together; it does **not** decide where the graph sits. A separate pass does +that, and the split is not incidental — the two obvious ways of merging them both fail, in ways worth +knowing before touching either. + +Gravity pulls every body the same amount whichever side of the target it sits and however far out. +Summed over a graph that is a step function of position: it counts bodies rather than measuring them, +so anywhere the counts balance it is exactly zero and nothing holds the graph anywhere at all. A +twelve-node chain settled 79 units to one side and stayed; pushed 600 the other way it came to rest 79 +units to the **other** side — the same distance out, on whichever side it arrived from, because both +are edges of the same dead band. And where the counts do balance is the median of the body centres, +which for a document with a dense cluster of literals on one side and a few large functions on the +other is nowhere near the middle of what is drawn: 200 units apart on `GraphCorpus.Counter`. + +Making gravity proportional to distance fixes both of those and costs something worse. A body further +out is then pulled harder, so wide nodes are squeezed closer together than narrow ones and settled +spacing depends on node size again — which is precisely what measuring repulsion across clear space +rather than between centres was for. Measured, a 400-wide pair settled 160 apart against a 60-wide +pair's 224, and `SettledPairs_KeepTheSameClearSpace_WhateverTheirSize` fails. + +So `LayoutCore.RecentreOnOrigin` slides the whole arrangement instead, positionally, after +integration. Every body moves by the same vector, so no distance between any two of them changes. +Three details: + +- **It is positional, not a force.** As a force it was clamped per body by `MaxForce`, and a body + already at the ceiling lost its share while its neighbour kept theirs — which reshaped the graph, + the one thing the design exists to avoid. +- **It stands down when any body is pinned or frozen**, since whoever pinned it is saying where the + graph goes. +- **It is gated on `OriginAnchorWeight`**, so a test isolating one force should set that to zero or a + position it asserts will include the slide. + +Measured over the corpus: mean offset from the origin 62.7 → 5.4 units, worst 172 → 19.5, with the +corpus score unchanged (1.194 → 1.166, inside noise). `RecentringTests` covers it, and a re-sweep of +all fifteen settings afterwards moved none of them — `GravityStrength` included, which stays at 50. + ### Demo UI tests Each example has a headless UI test project under `tests/.UITests/`, built on diff --git a/ForceDirectedLayout/LayoutCore.cs b/ForceDirectedLayout/LayoutCore.cs index 2ff635f..e6b8d92 100644 --- a/ForceDirectedLayout/LayoutCore.cs +++ b/ForceDirectedLayout/LayoutCore.cs @@ -187,6 +187,7 @@ public void Step(double deltaTime) ApplyDirectionalConstraints(); SeparateOverlaps(); + RecentreOnOrigin(); } double energy = 0.0; @@ -804,6 +805,17 @@ private void SeparateOverlaps() } } + /// + /// The fraction of its remaining offset from the arrangement is slid back + /// each substep, before scales it. + /// + /// + /// Not a setting, because it does not change where a graph comes to rest — only how quickly it gets + /// there. The resting place is the origin either way. Clamped to one so the graph can close the + /// offset but never travel past it, whatever weight is asked for. + /// + private const double RecentringRate = 0.25; + private void CalculateGravityForces() { if (bodyCount == 0) @@ -835,6 +847,75 @@ private void CalculateGravityForces() } } + /// + /// Slide the whole arrangement, as one piece, towards having its drawn bounding box centred on + /// . + /// + /// + /// Gravity cannot do this job, and it is worth being precise about why, because the obvious repair + /// makes things worse. + /// + /// Gravity pulls each body the same amount whichever side of the target it sits and however far out + /// it is. Summed over the graph that is a step function of position: it counts bodies rather than + /// measuring them, so anywhere the counts happen to balance it is exactly zero and nothing holds + /// the graph anywhere at all. A twelve-node chain settles 79 units to one side and stays; pushed + /// 600 the other way it comes to rest 79 units to the *other* side, the same distance out, because + /// both are edges of the same dead band. And where the counts do balance is the median of the body + /// centres, which for a document with a dense cluster of literals on one side and a few large + /// functions on the other is nowhere near the middle of what is drawn — 200 units apart on the + /// corpus's Counter graph. + /// + /// + /// Making gravity proportional to distance fixes both of those and costs something worse: a body + /// further from the target is then pulled harder, so a pair of wide nodes is squeezed closer + /// together than a pair of narrow ones, and settled spacing depends on node size again — which is + /// the whole thing measuring repulsion across clear space rather than between centres was for. + /// Measured, a 400-wide pair settled 160 apart against a 60-wide pair's 224. + /// + /// + /// So placement is separated from cohesion instead. This force is identical on every body, which + /// means it cannot change any distance between them: it can only slide the whole arrangement, and + /// it slides it until the box a reader sees is centred where it should be. Gravity is left to do + /// the one thing it is good at, which is holding the graph together. + /// + /// + private void RecentreOnOrigin() + { + double weight = Settings.OriginAnchorWeight; + if (bodyCount == 0 || weight <= 0) + { + return; + } + + double minX = double.MaxValue; + double minY = double.MaxValue; + double maxX = double.MinValue; + double maxY = double.MinValue; + + for (int i = 0; i < bodyCount; i++) + { + // A pinned or frozen body is placed by whoever pinned it, and sliding the graph would move + // it. One is enough to say where the graph goes, so the whole pass stands down. + if (bodies[i].IsPinned != 0 || bodies[i].IsFrozen != 0) + { + return; + } + + minX = Math.Min(minX, bodies[i].Position.X); + minY = Math.Min(minY, bodies[i].Position.Y); + maxX = Math.Max(maxX, bodies[i].Position.X + bodies[i].Dimensions.X); + maxY = Math.Max(maxY, bodies[i].Position.Y + bodies[i].Dimensions.Y); + } + + Vec2D drawnCentre = new((minX + maxX) * 0.5, (minY + maxY) * 0.5); + Vec2D shift = (WorldOrigin - drawnCentre) * Math.Min(weight * RecentringRate, 1.0); + + for (int i = 0; i < bodyCount; i++) + { + bodies[i].Position += shift; + } + } + private void IntegrateMotion(double dt) { double maxForce = Settings.MaxForce; diff --git a/ForceDirectedLayout/LayoutSettings.cs b/ForceDirectedLayout/LayoutSettings.cs index 79b77fc..4d04da7 100644 --- a/ForceDirectedLayout/LayoutSettings.cs +++ b/ForceDirectedLayout/LayoutSettings.cs @@ -48,7 +48,19 @@ public struct LayoutSettings /// public double LinkUntwistStrength; - /// Strength of the gravity force pulling each body toward the gravity target. + /// + /// Spring constant of the pull toward the gravity target, per unit of distance from it. + /// + /// + /// A spring rather than a constant pull, and the difference is what decides where a graph sits. + /// A pull of the same size wherever a body happens to be sums, over the whole graph, to a step + /// function of position: it depends on how many bodies lie each side of the target and not on how + /// far, so anywhere the counts balance it is exactly zero and nothing holds the graph in place at + /// all. A twelve-node chain measured that way settled 79 units to one side of the target and stayed + /// there, and pushed 600 units the other way it came to rest 79 units to the *other* side — the + /// same distance out, on whichever side it happened to arrive from, because both are edges of the + /// same dead band. Proportional to distance there is one resting place and it is the target. + /// public double GravityStrength; /// Blend factor from centroid (0) to world origin (1) for the gravity target. diff --git a/ForceDirectedLayout/PhysicsSettings.cs b/ForceDirectedLayout/PhysicsSettings.cs index d2c97d3..b574dce 100644 --- a/ForceDirectedLayout/PhysicsSettings.cs +++ b/ForceDirectedLayout/PhysicsSettings.cs @@ -40,7 +40,19 @@ public sealed record PhysicsSettings /// public double LinkUntwistStrength { get; init; } = 0.1; - /// Strength of the gravity force pulling each body toward the gravity target. + /// + /// Spring constant of the pull toward the gravity target, per unit of distance from it. + /// + /// + /// A spring rather than a constant pull, and the difference is what decides where a graph sits. + /// A pull of the same size wherever a body happens to be sums, over the whole graph, to a step + /// function of position: it depends on how many bodies lie each side of the target and not on how + /// far, so anywhere the counts balance it is exactly zero and nothing holds the graph in place at + /// all. A twelve-node chain measured that way settled 79 units to one side of the target and stayed + /// there, and pushed 600 units the other way it came to rest 79 units to the *other* side — the + /// same distance out, on whichever side it happened to arrive from, because both are edges of the + /// same dead band. Proportional to distance there is one resting place and it is the target. + /// public double GravityStrength { get; init; } = 50.0; /// Blend factor from centroid (0) to world origin (1) for the gravity target. diff --git a/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs b/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs index 0e1e89d..a587838 100644 --- a/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs +++ b/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs @@ -338,6 +338,11 @@ public void GenericFacade_Step_WithEdge_PullsNodesCloser() RestLinkLength = 225.0, OverlapMargin = 0, DampingFactor = 0.1, + + // No pull to the origin either, so a position in these tests means what the flattening force did + // to it. Left on, the recentring pass slides the pair bodily so its box straddles the origin, + // which moves both bodies equally and splays neither - true, and not what is being asked. + OriginAnchorWeight = 0, }; [TestMethod] diff --git a/tests/ForceDirectedLayout.Tests/RecentringTests.cs b/tests/ForceDirectedLayout.Tests/RecentringTests.cs new file mode 100644 index 0000000..8ea747e --- /dev/null +++ b/tests/ForceDirectedLayout.Tests/RecentringTests.cs @@ -0,0 +1,172 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ForceDirectedLayout.Tests; + +using System; +using ktsu.ForceDirectedLayout; +using ktsu.ForceDirectedLayout.Tests.Bench; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests that a settled graph sits where a reader is looking: centred on the world origin. +/// +/// +/// This is a placement property rather than a shape one, and nothing else in the suite covers it — +/// every other test asks what the arrangement looks like, not where it ended up. It went unnoticed +/// for exactly that reason: a graph can settle into a perfectly good shape a couple of hundred units +/// off to one side, and every metric reads well while a user watches their document sit against the +/// edge of the canvas. +/// +[TestClass] +public class RecentringTests +{ + /// Frames to settle for; the corpus settles well inside this. + private const int SettleFrames = 4000; + + /// The centre of the box a renderer would draw around every body. + /// The settled layout. + private static Vec2D DrawnCentre(LayoutCore core) + { + double minX = double.MaxValue; + double minY = double.MaxValue; + double maxX = double.MinValue; + double maxY = double.MinValue; + + for (int i = 0; i < core.BodyCount; i++) + { + BodyState body = core.Bodies[i]; + minX = Math.Min(minX, body.Position.X); + minY = Math.Min(minY, body.Position.Y); + maxX = Math.Max(maxX, body.Position.X + body.Dimensions.X); + maxY = Math.Max(maxY, body.Position.Y + body.Dimensions.Y); + } + + return new Vec2D((minX + maxX) * 0.5, (minY + maxY) * 0.5); + } + + /// Settles a corpus graph from one starting arrangement. + /// The graph to settle. + /// Which arrangement to start from. + private static LayoutCore Settle(BenchGraph graph, int seed) + { + LayoutCore core = graph.Start(LayoutSettings.Defaults, seed, 0.5); + for (int frame = 0; frame < SettleFrames; frame++) + { + core.Step(1.0 / 60.0); + } + + return core; + } + + [TestMethod] + public void EveryCorpusGraph_SettlesCentredOnTheOrigin() + { + foreach (BenchGraph graph in GraphCorpus.All) + { + Vec2D centre = DrawnCentre(Settle(graph, 7919 * 3)); + + // Generous against the drawn size rather than tight against zero: the recentring force is + // balanced against everything else rather than applied as a correction, so it comes to rest + // near the origin instead of exactly on it. + Assert.IsLessThan(60.0, Math.Abs(centre.X), $"{graph.Name} settled {centre.X:F0} off the origin horizontally"); + Assert.IsLessThan(60.0, Math.Abs(centre.Y), $"{graph.Name} settled {centre.Y:F0} off the origin vertically"); + } + } + + /// + /// Tests that a graph pushed off the origin comes back to the same place from either direction. + /// + /// + /// The regression this exists for. Gravity alone counts bodies rather than measuring them, so its + /// net is a step function of position and is exactly zero anywhere the counts balance — a band, not + /// a point. A chain settled 79 units to one side and stayed there, and shoved 600 the other way came + /// to rest 79 units to the other side: the same distance out, on whichever side it arrived from. + /// Asserting the two agree is what catches a return to that, where asserting either one alone would + /// not. + /// + [TestMethod] + public void AGraphPushedAside_ComesBackToTheSamePlaceFromEitherSide() + { + static Vec2D Resettle(double shove) + { + LayoutCore core = Settle(GraphCorpus.Chain, 7919 * 3); + + for (int i = 0; i < core.BodyCount; i++) + { + core.Bodies[i].Position += new Vec2D(shove, 0); + core.Bodies[i].Velocity = Vec2D.Zero; + } + + for (int frame = 0; frame < SettleFrames; frame++) + { + core.Step(1.0 / 60.0); + } + + return DrawnCentre(core); + } + + Vec2D fromTheRight = Resettle(600.0); + Vec2D fromTheLeft = Resettle(-600.0); + + Assert.AreEqual(fromTheLeft.X, fromTheRight.X, 20.0, + $"pushed either way it should come back to the same place; it settled {fromTheRight.X:F0} from one side and {fromTheLeft.X:F0} from the other"); + Assert.IsLessThan(60.0, Math.Abs(fromTheRight.X), $"and near the origin; it was {fromTheRight.X:F0}"); + } + + /// + /// Tests that the whole simulation is translation-equivariant: move where a graph starts and where + /// it is anchored by the same amount, and it settles into the same arrangement, moved by that much. + /// + /// + /// This is the property that lets the recentring pass exist. It slides every body by the same + /// vector, so it cannot change any distance between them — but that is only true if sliding a graph + /// is something the rest of the simulation is indifferent to, which is what this asserts. + /// + /// It matters because the obvious alternative does not have it. Gravity made proportional to + /// distance would centre a graph too, and would pull a distant body harder than a near one, so a + /// pair of wide nodes ends up squeezed closer together than a pair of narrow ones: measured, a + /// 400-wide pair settled 160 apart against a 60-wide pair's 224. Spacing that depends on how big a + /// node is drawn is the thing measuring repulsion across clear space was for. + /// + /// + [TestMethod] + public void SlidingAGraphAndItsAnchor_SettlesIntoTheSameArrangement() + { + Vec2D delta = new(5000, -3000); + + static LayoutCore SettleAt(Vec2D origin, Vec2D offset) + { + LayoutCore core = GraphCorpus.Counter.Start(LayoutSettings.Defaults, 7919 * 3, 0.5); + core.WorldOrigin = origin; + for (int i = 0; i < core.BodyCount; i++) + { + core.Bodies[i].Position += offset; + } + + for (int frame = 0; frame < SettleFrames; frame++) + { + core.Step(1.0 / 60.0); + } + + return core; + } + + LayoutCore here = SettleAt(Vec2D.Zero, Vec2D.Zero); + LayoutCore there = SettleAt(delta, delta); + + for (int i = 0; i < here.BodyCount; i++) + { + for (int j = i + 1; j < here.BodyCount; j++) + { + double a = (here.Bodies[i].Position - here.Bodies[j].Position).Length(); + double b = (there.Bodies[i].Position - there.Bodies[j].Position).Length(); + + Assert.AreEqual(a, b, 1.0, + $"bodies {i} and {j} sat {a:F1} apart at the origin and {b:F1} apart {delta.X:F0} away"); + } + } + + Assert.AreEqual(delta.X, DrawnCentre(there).X - DrawnCentre(here).X, 20.0, + "and the second should have settled a whole delta away from the first"); + } +}