diff --git a/CLAUDE.md b/CLAUDE.md index 9b3e3ad..f5e9574 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,6 +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. - **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. @@ -52,6 +53,9 @@ This is the **ktsu ImGui Suite**, a collection of .NET libraries for building De PNG, JPEG, BMP and TGA decoders and the resampler. `TestImageBuilder` encodes PNG, BMP and TGA files in memory so the decoders can be driven over their whole feature matrix without binary fixtures; the JPEG cases, which need a real encoder, are small base64 constants in `JpegDecoderTests`. +- `tests/ForceDirectedLayout.Tests/` - The layout simulation: per-force unit tests, the overlap pass, + repulsion, and `Bench/` — the benchmark harness every layout claim is measured with. See + [Layout benchmarking](#layout-benchmarking) below. - `tests/NodeGraph.Tests/` - Node graph attribute and type utility tests - `tests/ImGui.NodeEditor.Tests/` - Engine, factory and rendering tests for the node editor. The engine and factory ones need no context; `NodeRenderingTests`, `ZoomTests` and @@ -454,6 +458,66 @@ Do **not** pass `--nologo` to `dotnet test`. On Microsoft Testing Platform proje `Zero tests ran` and exit code 5 instead of running anything, which looks exactly like a broken test project (dotnet/sdk#55309). Running the produced test executable directly is the way to confirm. +### Layout benchmarking + +Iterating on a force in `ktsu.ForceDirectedLayout` by running a graph once and looking at the result +does not work: the simulation is chaotic, so which local minimum one starting arrangement falls into +says nothing about the change that was made. A single-start assertion flips between passing and +failing across parameter values that are all perfectly reasonable — `Repulsion_IsWhatSpreadsAGraphOut` +used to, passing at repulsion 1,200,000 and 600,000, failing at 800,000, and passing again at 400,000. + +`tests/ForceDirectedLayout.Tests/Bench/` is the harness that replaces that: + +- **`GraphCorpus`** — four graphs that break a layout differently. `Counter` is a real twenty-node + document with sizes running from a 60-wide literal to a 118x180 function; `Chain` is the shape that + most wants to be a horizontal row; `FanIn` is eight sources arriving at eight pins on one target, + which is where crossings come from; `MixedSizes` alternates 400-wide slabs with 50-wide literals. + Node sizes and pin rows are not decoration — repulsion measures clear space between boxes, every + angle force measures between pins, and a graph of equal-sized points exercises none of it. +- **`LayoutMetrics`** — settled area, mean edge angle, links drawn across a body they are no end of, + tightest and mean clear gap, worst overlap, twisted link pairs, and whether it settled. Read them as + a row: a collapse into a crushed ribbon flatters both the area and the angle while being the worst + outcome available. +- **`LayoutBench.Run` / `.Sweep` / `.Compare` / `.Table`** — settles a configuration over several + starting arrangements (walked from nodes piled on top of each other to nodes flung a thousand units + apart) and renders the rows as a fixed-width table. Deterministic: the same settings measure the + same twice, so the difference between two rows is the setting and nothing else. +- **`LayoutSvg`** — writes a settled graph to SVG, links drawn first as the cubic the renderer + actually draws and nodes over them, so a link hidden in the picture is a link hidden in the editor. + Overlapping bodies are outlined in red. No window, no GPU, no ImGui context. + +To iterate: add a scratch `[TestMethod]` that prints a sweep, run the suite, read the column that +should have moved. + +```csharp +Console.WriteLine(LayoutBench.Table(LayoutBench.Sweep( + GraphCorpus.Counter, LayoutSettings.Defaults, "repulsion", + [300_000, 600_000, 1_200_000], + (s, v) => s with { RepulsionStrength = v }))); + +LayoutCore core = GraphCorpus.Counter.Start(LayoutSettings.Defaults, seed: 1, spread: 0.5); +core.Solve(maxIterations: 6000, tolerance: 0); +LayoutSvg.Write(core, "/tmp/counter.svg"); +``` + +Two things that bite: + +- **Console output only shows for tests the runner renders a block for**, which by default is failing + ones. Pass `--show-stdout All --show-test-results all` to the test executable to see a sweep printed + by a passing test. +- **The analyzers apply to scratch tests too.** `IDE0005` (unused using), `IDE2001` (embedded + statement on one line) and `IDE0055` (formatting) are errors here, so a quick `{ s.X = v; return s; }` + lambda will not build. Use a `with` expression, or put the body on its own lines. + +`Corpus_SettlesIntoAReadableShape_UnderTheDefaults` is the quality gate a layout change is expected to +break if it makes things worse. Its per-graph thresholds are current behaviour with headroom, not +targets — and two of them are loose because of a real defect the corpus exposed: **centre gravity coils +a long chain**. A plain twelve-node chain settles at about 53 degrees mean edge angle with only two +starts in six reading left to right, and it is not a settling-time problem (4000, 12000 and 30000 +frames all land on 52.6). Sweeping `GravityStrength` over the same starts gives 0.6 degrees at 0, 1.9 +at 10, 52.9 at the default 50, and 58.4 at 200; raising `DirectionalBias` makes it worse rather than +better, because ordering pairs left-to-right says nothing about the shape of the whole. + ### Demo UI tests Each example has a headless UI test project under `tests/.UITests/`, built on diff --git a/ForceDirectedLayout/README.md b/ForceDirectedLayout/README.md index a9ebf57..5860e9d 100644 --- a/ForceDirectedLayout/README.md +++ b/ForceDirectedLayout/README.md @@ -124,6 +124,8 @@ PhysicsSettings settings = new() }; ``` +These values were not guessed. `tests/ForceDirectedLayout.Tests/Bench/` settles a corpus of graphs over a range of starting arrangements and reports what a layout measures — settled area, mean edge angle, links drawn across a body they are no end of, tightest clear gap, overlaps, crossed link pairs — because the simulation is chaotic and a single run says nothing. `LayoutBench.Sweep` walks one setting across a range and prints the rows as a table, `LayoutBench.Compare` puts named variants side by side, and `LayoutSvg` writes a settled graph out as SVG so it can be looked at rather than only read. Changing what a force measures changes the units its strength is in, so that is how a new default gets found. + ### From native code `ForceDirectedLayout.Native` publishes a shared library with a C entry point set — `Layout_Create`, `Layout_Destroy`, `Layout_SetSettings`, `Layout_SetNodes`, `Layout_SetEdges`, `Layout_Step`, `Layout_Solve`, `Layout_GetPositions`, `Layout_SetPinned`, `Layout_GetIndexOf`, `Layout_GetNodeCount` and `Layout_GetLastErrorMessage` — and ships `ktsu_force_directed_layout.h` beside the binary. The settings and node/edge structs are laid out sequentially and cross the ABI unchanged. diff --git a/tests/ForceDirectedLayout.Tests/Bench/BenchGraph.cs b/tests/ForceDirectedLayout.Tests/Bench/BenchGraph.cs new file mode 100644 index 0000000..fc32419 --- /dev/null +++ b/tests/ForceDirectedLayout.Tests/Bench/BenchGraph.cs @@ -0,0 +1,156 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ForceDirectedLayout.Tests.Bench; + +using System; +using System.Collections.Generic; +using ktsu.ForceDirectedLayout; + +/// A node in a benchmark graph, sized the way a node editor would draw it. +/// Stable identifier, unique within the graph. +/// Drawn width. +/// Drawn height. +/// How many input pin rows it has, which is the row its outputs start at. +public sealed record BenchNode(int Id, double Width, double Height, int InputRows); + +/// An edge that knows which pin row it leaves and which it arrives at. +/// Id of the source node. +/// Which of the source's output rows it leaves from, counted from the first. +/// Id of the target node. +/// Which of the target's input rows it arrives at. +public sealed record BenchEdge(int From, int FromRow, int To, int ToRow); + +/// +/// A graph to measure a layout against: nodes with real sizes, and edges that attach at real pins. +/// +/// +/// Sizes and pin rows are not decoration. Repulsion is measured across the clear space between two +/// boxes, the link spring and every angle force are measured between the pins a link is drawn +/// between, and the untwist force only has an order to preserve when two links arrive at different +/// pins. A graph of equal-sized points exercises none of that, so a layout can look perfect on one +/// and be unusable on a real document. +/// +public sealed class BenchGraph +{ + /// Height of a node's header, above its first pin row. + private const double HeaderHeight = 28.0; + + /// Vertical pitch from one pin row to the next. + private const double RowPitch = 21.0; + + /// Offset from a row's top to the pin itself. + private const double RowCentre = 10.0; + + /// Clearance kept between the last usable pin position and a node's bottom edge. + private const double BottomMargin = 8.0; + + /// Constructs a graph from its nodes and edges. + /// What to call it in a report. + /// The nodes, each with a distinct id. + /// The edges between them. + public BenchGraph(string name, IReadOnlyList nodes, IReadOnlyList edges) + { + ArgumentNullException.ThrowIfNull(nodes); + ArgumentNullException.ThrowIfNull(edges); + + Name = name; + Nodes = nodes; + Edges = edges; + + Dictionary indexById = []; + for (int i = 0; i < nodes.Count; i++) + { + indexById[nodes[i].Id] = i; + } + IndexById = indexById; + } + + /// What to call this graph in a report. + public string Name { get; } + + /// The nodes, in the order they are submitted to a core. + public IReadOnlyList Nodes { get; } + + /// The edges between them. + public IReadOnlyList Edges { get; } + + /// Body index of each node id, matching the order is submitted in. + public IReadOnlyDictionary IndexById { get; } + + /// + /// Where a pin sits on a node, relative to the node's own origin. + /// + /// The node the pin belongs to. + /// Which row, counted from the node's first input row. + /// True for an output pin, on the node's right edge. + public static Vec2D PinOffset(BenchNode node, int row, bool onRight) + { + ArgumentNullException.ThrowIfNull(node); + + double y = Math.Min(HeaderHeight + (row * RowPitch) + RowCentre, node.Height - BottomMargin); + return new Vec2D(onRight ? node.Width : 0.0, y); + } + + /// + /// Builds a core holding this graph, scattered into one particular starting arrangement. + /// + /// Settings for the core; is forced on. + /// Chooses the arrangement. The same seed always gives the same one. + /// + /// How far the scatter reaches, as a fraction of a thousand units. Small values pile every node + /// almost on top of the others and large ones fling them apart, and a layout that only settles + /// from one of those is not settling, it is inheriting its answer from where it started. + /// + public LayoutCore Start(LayoutSettings settings, int seed, double spread) + { + settings.Enabled = 1; + LayoutCore core = new() { Settings = settings }; + + core.ResizeBodies(Nodes.Count); + int state = seed; + for (int i = 0; i < Nodes.Count; i++) + { + core.Bodies[i] = new BodyState + { + Id = Nodes[i].Id, + Position = new Vec2D(NextScatter(ref state, spread), NextScatter(ref state, spread)), + Dimensions = new Vec2D(Nodes[i].Width, Nodes[i].Height), + }; + } + + core.ResizeEdges(Edges.Count); + for (int e = 0; e < Edges.Count; e++) + { + BenchEdge edge = Edges[e]; + BenchNode from = Nodes[IndexById[edge.From]]; + BenchNode to = Nodes[IndexById[edge.To]]; + + core.Edges[e] = new EdgeRef + { + SourceIndex = IndexById[edge.From], + TargetIndex = IndexById[edge.To], + SourcePinOffset = PinOffset(from, from.InputRows + edge.FromRow, onRight: true), + TargetPinOffset = PinOffset(to, edge.ToRow, onRight: false), + HasPinOffsets = 1, + }; + } + + return core; + } + + /// + /// One coordinate of the scatter, from a linear congruential generator. + /// + /// + /// Deliberately not : a starting arrangement has to be identical on every + /// machine and every runtime, or a measurement taken today cannot be compared with one written + /// into a comment last month. + /// + /// Generator state, advanced by the call. + /// Scatter reach, as a fraction of a thousand units. + private static double NextScatter(ref int state, double spread) + { + state = ((state * 1103515245) + 12345) & 0x7fffffff; + return state % 1000 * spread; + } +} diff --git a/tests/ForceDirectedLayout.Tests/Bench/GraphCorpus.cs b/tests/ForceDirectedLayout.Tests/Bench/GraphCorpus.cs new file mode 100644 index 0000000..f4c754a --- /dev/null +++ b/tests/ForceDirectedLayout.Tests/Bench/GraphCorpus.cs @@ -0,0 +1,153 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ForceDirectedLayout.Tests.Bench; + +using System.Collections.Generic; + +/// +/// The graphs a layout change is measured against. +/// +/// +/// Each is a shape that breaks a layout differently, which is why there is more than one. A change +/// that helps a wide fan-in can easily hurt a deep chain, and a single graph will not say so. +/// +public static class GraphCorpus +{ + /// + /// The Counter document as a node editor lays it out: twenty nodes, twenty-one edges, five levels + /// deep, with sizes running from a 60-wide literal to a 118x180 function. + /// + /// + /// This is the reference graph. It is a real document rather than a synthetic shape, its nodes + /// differ in size by a factor of three in each direction, and it has every feature the forces care + /// about: a backward edge, several nodes feeding one, and pins spread down tall bodies. + /// + public static BenchGraph Counter { get; } = BuildCounter(); + + /// A single chain, which is the shape that most wants to be a straight horizontal line. + /// + /// Nothing here competes: there is one path, every node has one input and one output, and a + /// correct layout is a row. It is the cheapest way to catch a change that leaves edges steep or a + /// graph taller than it is wide. + /// + public static BenchGraph Chain { get; } = BuildChain(12); + + /// + /// Eight sources feeding one wide node, which is where crossings and crowding come from. + /// + /// + /// Every source arrives at a different pin on the same target, so the vertical order of the + /// sources is forced by the order of the pins — this is the arrangement the untwist force exists + /// for. The sources are all the same size and the target is much larger, which is exactly the + /// pairing a centre-measured repulsion got wrong. + /// + public static BenchGraph FanIn { get; } = BuildFanIn(8); + + /// + /// A graph of wildly mismatched node sizes, which is what spacing measured between centres broke on. + /// + /// + /// Alternating 400-wide slabs and 50-wide literals, all unlinked to each other except through a + /// spine. If spacing depends on how big a node is rather than on the room around it, the slabs end + /// up crowded and the literals marooned, and says so. + /// + public static BenchGraph MixedSizes { get; } = BuildMixedSizes(); + + /// Every graph in the corpus, for a change that should be measured against all of them. + public static IReadOnlyList All { get; } = [Counter, Chain, FanIn, MixedSizes]; + + /// Builds the Counter document. + private static BenchGraph BuildCounter() + { + BenchNode[] nodes = + [ + new(1, 60, 50, 0), new(2, 115, 62, 1), // 0 -> var count + new(3, 60, 50, 0), new(4, 115, 62, 1), // 1 -> var step + new(5, 110, 50, 0), // param amount + new(6, 75, 50, 0), new(7, 75, 50, 0), new(8, 75, 50, 0), new(9, 75, 50, 0), + new(10, 90, 75, 2), // binary + (Add) + new(11, 90, 70, 2), // assign + new(12, 105, 60, 1), // return (Add) + new(13, 118, 180, 7), // function Add + new(14, 75, 50, 0), new(15, 75, 50, 0), + new(16, 90, 75, 2), // binary + (Next) + new(17, 115, 62, 1), // var result + new(18, 105, 60, 1), // return (Next) + new(19, 118, 160, 6), // function Next + new(20, 117, 160, 6), // class Counter + ]; + + BenchEdge[] edges = + [ + new(1, 0, 2, 0), new(2, 0, 20, 0), + new(3, 0, 4, 0), new(4, 0, 20, 1), + new(5, 0, 13, 0), + new(6, 0, 11, 0), new(7, 0, 10, 0), new(8, 0, 10, 1), new(10, 0, 11, 1), + new(11, 0, 13, 3), new(9, 0, 12, 0), new(12, 0, 13, 4), new(13, 0, 20, 2), + new(14, 0, 16, 0), new(15, 0, 16, 1), new(16, 0, 17, 0), new(17, 0, 19, 2), + new(18, 0, 19, 3), new(19, 0, 20, 3), + new(9, 0, 18, 0), + ]; + + return new BenchGraph("Counter", nodes, edges); + } + + /// Builds a single chain of the given length. + /// How many nodes the chain has. + private static BenchGraph BuildChain(int length) + { + List nodes = []; + List edges = []; + + for (int i = 0; i < length; i++) + { + nodes.Add(new BenchNode(i + 1, 100, 55, i == 0 ? 0 : 1)); + if (i > 0) + { + edges.Add(new BenchEdge(i, 0, i + 1, 0)); + } + } + + return new BenchGraph("Chain", nodes, edges); + } + + /// Builds a fan of sources into one target. + /// How many sources feed the target. + private static BenchGraph BuildFanIn(int sources) + { + List nodes = []; + List edges = []; + + for (int i = 0; i < sources; i++) + { + nodes.Add(new BenchNode(i + 1, 70, 50, 0)); + edges.Add(new BenchEdge(i + 1, 0, sources + 1, i)); + } + + // Tall enough to give every arriving link its own pin row. The height is computed in double + // rather than widened from an int expression, so a large source count cannot overflow it. + nodes.Add(new BenchNode(sources + 1, 130, 40.0 + (sources * 21.0), sources)); + + return new BenchGraph("FanIn", nodes, edges); + } + + /// Builds the mismatched-size graph. + private static BenchGraph BuildMixedSizes() + { + List nodes = []; + List edges = []; + + // A spine of alternating slabs and literals, each feeding the next. + for (int i = 0; i < 10; i++) + { + bool slab = i % 2 == 0; + nodes.Add(new BenchNode(i + 1, slab ? 400 : 50, slab ? 120 : 40, i == 0 ? 0 : 1)); + if (i > 0) + { + edges.Add(new BenchEdge(i, 0, i + 1, 0)); + } + } + + return new BenchGraph("MixedSizes", nodes, edges); + } +} diff --git a/tests/ForceDirectedLayout.Tests/Bench/LayoutBench.cs b/tests/ForceDirectedLayout.Tests/Bench/LayoutBench.cs new file mode 100644 index 0000000..a38037d --- /dev/null +++ b/tests/ForceDirectedLayout.Tests/Bench/LayoutBench.cs @@ -0,0 +1,247 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ForceDirectedLayout.Tests.Bench; + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using ktsu.ForceDirectedLayout; + +/// How a benchmark run is set up. +/// +/// How many starting arrangements to settle. One is not a measurement: the simulation is chaotic, and +/// a single start will happily report a change as an improvement one run and a regression the next. +/// +/// Frames to settle for, at each. +/// +/// Frame at which to ask whether the graph is readable yet. Settling eventually is not enough — an +/// editor's user watches it unfold, and ten seconds is about as long as anyone waits. +/// +/// Simulated seconds per frame. +public sealed record BenchOptions( + int Starts = 10, + int Frames = 6000, + int ReadableAfter = 600, + double FrameDelta = 1.0 / 60.0) +{ + /// The defaults, which are what every comparison in the tests uses. + public static BenchOptions Default { get; } = new(); +} + +/// What a benchmark run found, aggregated over its starting arrangements. +/// What this run is called in a table. +/// How many arrangements were settled. +/// Mean bounding-box area. +/// Mean angle off horizontal across every edge of every start. +/// Mean count of links drawn across a node they are no end of. +/// Mean, over starts, of each start's closest pair. +/// Mean clear space over every pair of every start. +/// Deepest overlap seen in any start. Anything above zero is a defect. +/// Mean count of crossed link pairs. +/// How many starts reported themselves stable. +/// How many were already readable at . +/// Each start's own metrics, for a caller that wants the spread and not the mean. +public sealed record BenchResult( + string Label, + int Starts, + double MeanArea, + double MeanEdgeAngle, + double MeanLinksOverBodies, + double MeanTightestGap, + double MeanClearGap, + double WorstOverlap, + double MeanTwistedPairs, + int SettledStarts, + int ReadableStarts, + IReadOnlyList PerStart); + +/// +/// Settles a benchmark graph over several starting arrangements and reports what it measures. +/// +/// +/// This exists because the obvious way to judge a layout change — run the graph once, look at the +/// number — does not work. The simulation is chaotic: which local minimum one start happens to fall +/// into says nothing about the change that was made, and a single-start assertion will flip between +/// passing and failing across parameter values that are all perfectly reasonable. Settling the same +/// claim over a range of starts, from nodes piled on top of each other to nodes flung far apart, is +/// what turns a number into evidence. +/// +/// Use to measure one configuration, to walk one setting across +/// a range, and to put named variants side by side. All three return rows that +/// renders, so iterating on a force is: change it, run a scratch test method that +/// prints a sweep, and read the column that should have moved. +/// +/// +public static class LayoutBench +{ + /// Narrowest scatter to start from: every node piled almost on top of the others. + private const double TightestSpread = 0.05; + + /// Widest scatter to start from: nodes flung across a thousand units. + private const double WidestSpread = 1.0; + + /// Settles one configuration over arrangements. + /// The graph to settle. + /// The settings to settle it under. + /// What to call this run in a table. + /// Run shape, or for . + public static BenchResult Run(BenchGraph graph, LayoutSettings settings, string label, BenchOptions? options = null) + { + ArgumentNullException.ThrowIfNull(graph); + + BenchOptions shape = options ?? BenchOptions.Default; + List perStart = []; + int readable = 0; + + for (int start = 0; start < shape.Starts; start++) + { + LayoutCore core = graph.Start(settings, SeedFor(start), SpreadFor(start, shape.Starts)); + + for (int frame = 0; frame < shape.ReadableAfter && frame < shape.Frames; frame++) + { + core.Step(shape.FrameDelta); + } + + if (LayoutMetrics.Measure(core).Readable) + { + readable++; + } + + for (int frame = shape.ReadableAfter; frame < shape.Frames; frame++) + { + core.Step(shape.FrameDelta); + } + + perStart.Add(LayoutMetrics.Measure(core)); + } + + return new BenchResult( + label, + perStart.Count, + perStart.Average(m => m.Area), + perStart.Average(m => m.MeanEdgeAngle), + perStart.Average(m => (double)m.LinksOverBodies), + perStart.Average(m => m.TightestClearGap), + perStart.Average(m => m.MeanClearGap), + perStart.Max(m => m.WorstOverlap), + perStart.Average(m => (double)m.TwistedPairs), + perStart.Count(m => m.Settled), + readable, + perStart); + } + + /// + /// Walks one setting across a range of values, holding everything else where it is. + /// + /// + /// This is how a default gets chosen. Changing what a force measures changes the units its + /// strength is in, so the old value is meaningless and the new one has to be found: sweep it, + /// and read off where the settled graph has the room it used to have. + /// + /// The graph to settle. + /// Settings to vary from. + /// Name of the setting being swept, for the row labels. + /// The values to try. + /// Writes one value into a copy of the settings. + /// Run shape, or for . + public static IReadOnlyList Sweep( + BenchGraph graph, + LayoutSettings baseline, + string setting, + IReadOnlyList values, + Func apply, + BenchOptions? options = null) + { + ArgumentNullException.ThrowIfNull(values); + ArgumentNullException.ThrowIfNull(apply); + + List rows = []; + foreach (double value in values) + { + string label = $"{setting}={value.ToString("G6", CultureInfo.InvariantCulture)}"; + rows.Add(Run(graph, apply(baseline, value), label, options)); + } + + return rows; + } + + /// Settles several named configurations of the same graph, for a side-by-side read. + /// The graph to settle. + /// Run shape, or for . + /// Each variant's label and settings. + public static IReadOnlyList Compare( + BenchGraph graph, + BenchOptions? options, + params (string Label, LayoutSettings Settings)[] variants) + { + ArgumentNullException.ThrowIfNull(variants); + + return [.. variants.Select(v => Run(graph, v.Settings, v.Label, options))]; + } + + /// Renders rows as a fixed-width table, for printing from a test. + /// The rows to render, in the order they should appear. + public static string Table(IEnumerable rows) + { + ArgumentNullException.ThrowIfNull(rows); + + List ordered = [.. rows]; + int labelWidth = Math.Max(8, ordered.Count == 0 ? 8 : ordered.Max(r => r.Label.Length)); + + System.Text.StringBuilder text = new(); + text.Append(Cell("variant", labelWidth)) + .Append(Cell("area", 12)) + .Append(Cell("angle", 7)) + .Append(Cell("overBody", 9)) + .Append(Cell("tightGap", 9)) + .Append(Cell("meanGap", 9)) + .Append(Cell("overlap", 8)) + .Append(Cell("twisted", 8)) + .Append(Cell("settled", 8)) + .AppendLine(Cell("readable", 9)); + + foreach (BenchResult row in ordered) + { + text.Append(Cell(row.Label, labelWidth)) + .Append(Cell(row.MeanArea.ToString("F0", CultureInfo.InvariantCulture), 12)) + .Append(Cell(row.MeanEdgeAngle.ToString("F1", CultureInfo.InvariantCulture), 7)) + .Append(Cell(row.MeanLinksOverBodies.ToString("F1", CultureInfo.InvariantCulture), 9)) + .Append(Cell(row.MeanTightestGap.ToString("F1", CultureInfo.InvariantCulture), 9)) + .Append(Cell(row.MeanClearGap.ToString("F1", CultureInfo.InvariantCulture), 9)) + .Append(Cell(row.WorstOverlap.ToString("F1", CultureInfo.InvariantCulture), 8)) + .Append(Cell(row.MeanTwistedPairs.ToString("F1", CultureInfo.InvariantCulture), 8)) + .Append(Cell($"{row.SettledStarts}/{row.Starts}", 8)) + .AppendLine(Cell($"{row.ReadableStarts}/{row.Starts}", 9)); + } + + return text.ToString(); + } + + /// One padded table cell. + /// Cell contents. + /// Column width, including the separating space. + private static string Cell(string text, int width) => text.PadLeft(width) + " "; + + /// + /// The seed for one start. Fixed multiples rather than a sequence, so adding a start to a run does + /// not renumber the ones already measured. + /// + /// Index of the start. + private static int SeedFor(int start) => (start * 7919) + 1; + + /// + /// The scatter reach for one start, walked from tightest to widest across the run. + /// + /// + /// Both ends matter and they fail differently. A tight scatter starts every node overlapping, so + /// it exercises the floor on repulsion and the overlap pass; a wide one starts them further apart + /// than any force wants them, so it exercises how fast the graph can actually travel. + /// + /// Index of the start. + /// How many starts the run has. + private static double SpreadFor(int start, int starts) => + starts <= 1 + ? TightestSpread + : TightestSpread + ((WidestSpread - TightestSpread) * start / (starts - 1)); +} diff --git a/tests/ForceDirectedLayout.Tests/Bench/LayoutBenchTests.cs b/tests/ForceDirectedLayout.Tests/Bench/LayoutBenchTests.cs new file mode 100644 index 0000000..7b2df34 --- /dev/null +++ b/tests/ForceDirectedLayout.Tests/Bench/LayoutBenchTests.cs @@ -0,0 +1,376 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ForceDirectedLayout.Tests.Bench; + +using System; +using System.Collections.Generic; +using ktsu.ForceDirectedLayout; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +/// +/// Tests the benchmark harness itself, and holds the quality gate the corpus has to clear. +/// +/// +/// The harness is what every claim about a layout force is measured with, so it needs to be right in +/// its own tests rather than trusted: a metric that silently counted the wrong thing would let a +/// regression through while reading like evidence. +/// +/// To iterate on a force, add a scratch test that prints a sweep and read the column that should +/// have moved: +/// +/// +/// LayoutSettings baseline = LayoutSettings.Defaults; +/// Console.WriteLine(LayoutBench.Table(LayoutBench.Sweep( +/// GraphCorpus.Counter, baseline, "repulsion", +/// [300_000, 600_000, 1_200_000], +/// (s, v) => s with { RepulsionStrength = v }))); +/// +/// +/// And to see one of them rather than read it, settle a single start and write it out: +/// +/// +/// LayoutCore core = GraphCorpus.Counter.Start(baseline, seed: 1, spread: 0.5); +/// core.Solve(maxIterations: 6000, tolerance: 0); +/// LayoutSvg.Write(core, "/tmp/counter.svg", LayoutMetrics.Measure(core).ToString()); +/// +/// +[TestClass] +public class LayoutBenchTests +{ + /// Settings with everything switched off, so a test can place bodies and measure them. + private static LayoutSettings Inert() + { + LayoutSettings s = LayoutSettings.Defaults; + s.Enabled = 0; + return s; + } + + /// A core holding bodies at exactly the positions given, with no simulation run. + /// Id, position and dimensions of each body. + private static LayoutCore Arrange(params (int Id, double X, double Y, double W, double H)[] bodies) + { + LayoutCore core = new() { Settings = Inert() }; + core.ResizeBodies(bodies.Length); + for (int i = 0; i < bodies.Length; i++) + { + core.Bodies[i] = new BodyState + { + Id = bodies[i].Id, + Position = new Vec2D(bodies[i].X, bodies[i].Y), + Dimensions = new Vec2D(bodies[i].W, bodies[i].H), + }; + } + + return core; + } + + [TestMethod] + public void Metrics_OnAKnownArrangement_ReportWhatIsThere() + { + // Two 100x50 boxes, 300 apart horizontally and level, so 200 of clear space between them. + LayoutCore core = Arrange((1, 0, 0, 100, 50), (2, 300, 0, 100, 50)); + LayoutMetrics metrics = LayoutMetrics.Measure(core); + + Assert.AreEqual(400.0, metrics.Width, 0.001, "the bounding box spans from the first left edge to the second right"); + Assert.AreEqual(50.0, metrics.Height, 0.001); + Assert.AreEqual(200.0, metrics.TightestClearGap, 0.001, "the clear space is the gap between the facing edges"); + Assert.AreEqual(200.0, metrics.MeanClearGap, 0.001, "and with one pair the mean is that same gap"); + Assert.AreEqual(0.0, metrics.WorstOverlap, 0.001); + Assert.AreEqual(0, metrics.LinksOverBodies); + Assert.IsTrue(metrics.Readable, "wider than it is tall, with no edges to be steep"); + } + + [TestMethod] + public void Metrics_OnOverlappingBoxes_ReportTheDepthAndNoGap() + { + // Overlapping by 40 across and 30 down, so the shallower axis is the depth. + LayoutCore core = Arrange((1, 0, 0, 100, 50), (2, 60, 20, 100, 50)); + LayoutMetrics metrics = LayoutMetrics.Measure(core); + + Assert.AreEqual(0.0, metrics.TightestClearGap, 0.001, "overlapping boxes have no clear space between them"); + Assert.AreEqual(30.0, metrics.WorstOverlap, 0.001, "and the overlap is measured on the shallower axis"); + } + + [TestMethod] + public void Metrics_CountALinkDrawnAcrossABodyItIsNoEndOf() + { + // Three in a row, with a link from the first to the last straight through the middle one. + LayoutCore core = Arrange((1, 0, 0, 100, 50), (2, 200, 0, 100, 50), (3, 400, 0, 100, 50)); + core.ResizeEdges(1); + core.Edges[0] = new EdgeRef + { + SourceIndex = 0, + TargetIndex = 2, + SourcePinOffset = new Vec2D(100, 25), + TargetPinOffset = new Vec2D(0, 25), + HasPinOffsets = 1, + }; + + Assert.AreEqual(1, LayoutMetrics.Measure(core).LinksOverBodies, + "the link runs the width of the middle body, which would hide it"); + } + + [TestMethod] + public void Metrics_CountAPairOfLinksThatCross() + { + // Two sources feeding one target, each arriving at the pin the other one should have. + LayoutCore core = Arrange((1, 0, 200, 100, 60), (2, 0, 0, 100, 60), (3, 400, 0, 120, 140)); + core.ResizeEdges(2); + core.Edges[0] = new EdgeRef + { + SourceIndex = 0, + TargetIndex = 2, + SourcePinOffset = new Vec2D(100, 30), + TargetPinOffset = new Vec2D(0, 40), + HasPinOffsets = 1, + }; + core.Edges[1] = new EdgeRef + { + SourceIndex = 1, + TargetIndex = 2, + SourcePinOffset = new Vec2D(100, 30), + TargetPinOffset = new Vec2D(0, 100), + HasPinOffsets = 1, + }; + + Assert.AreEqual(1, LayoutMetrics.Measure(core).TwistedPairs, + "the body feeding the upper pin sits below the one feeding the lower pin"); + } + + [TestMethod] + public void BenchGraph_Start_IsReproducibleAndScattersWithTheSpread() + { + LayoutSettings settings = LayoutSettings.Defaults; + + LayoutCore first = GraphCorpus.Counter.Start(settings, seed: 42, spread: 0.5); + LayoutCore again = GraphCorpus.Counter.Start(settings, seed: 42, spread: 0.5); + LayoutCore elsewhere = GraphCorpus.Counter.Start(settings, seed: 43, spread: 0.5); + + Assert.AreEqual(first.Bodies[0].Position, again.Bodies[0].Position, "the same seed gives the same arrangement"); + Assert.AreNotEqual(first.Bodies[0].Position, elsewhere.Bodies[0].Position, "a different seed gives a different one"); + + // A tight spread piles the graph up; a wide one flings it out. + LayoutCore tight = GraphCorpus.Counter.Start(settings, seed: 42, spread: 0.05); + Assert.IsLessThan(LayoutMetrics.Measure(first).Width, LayoutMetrics.Measure(tight).Width, + "a tighter spread should start the graph in a smaller area"); + } + + [TestMethod] + public void BenchGraph_Start_AttachesEdgesToPinsWithinTheirNodes() + { + LayoutCore core = GraphCorpus.Counter.Start(LayoutSettings.Defaults, seed: 1, spread: 0.5); + + for (int e = 0; e < core.EdgeCount; e++) + { + EdgeRef edge = core.Edges[e]; + Assert.AreNotEqual(0, edge.HasPinOffsets, "every corpus edge should carry pin offsets"); + + BodyState source = core.Bodies[edge.SourceIndex]; + BodyState target = core.Bodies[edge.TargetIndex]; + + Assert.AreEqual(source.Dimensions.X, edge.SourcePinOffset.X, 0.001, "an output pin sits on its node's right edge"); + Assert.AreEqual(0.0, edge.TargetPinOffset.X, 0.001, "and an input pin on its node's left edge"); + Assert.IsTrue(edge.SourcePinOffset.Y >= 0 && edge.SourcePinOffset.Y <= source.Dimensions.Y, + $"the source pin should sit within its node's height; it was at {edge.SourcePinOffset.Y}"); + Assert.IsTrue(edge.TargetPinOffset.Y >= 0 && edge.TargetPinOffset.Y <= target.Dimensions.Y, + $"and so should the target pin; it was at {edge.TargetPinOffset.Y}"); + } + } + + [TestMethod] + public void Bench_MeasuringTwice_GivesTheSameAnswer() + { + // Determinism is the whole point: a sweep is only readable if the difference between two rows + // is the setting and nothing else. + BenchOptions quick = new(Starts: 3, Frames: 400, ReadableAfter: 200); + + BenchResult first = LayoutBench.Run(GraphCorpus.Chain, LayoutSettings.Defaults, "first", quick); + BenchResult again = LayoutBench.Run(GraphCorpus.Chain, LayoutSettings.Defaults, "again", quick); + + Assert.AreEqual(first.MeanArea, again.MeanArea, 0.0001); + Assert.AreEqual(first.MeanTightestGap, again.MeanTightestGap, 0.0001); + Assert.AreEqual(first.SettledStarts, again.SettledStarts); + } + + [TestMethod] + public void Sweep_MoreRepulsion_LeavesMoreRoom() + { + // The worked example from this class's remarks, kept as a test so it cannot rot. It is also the + // harness's own sanity check: if turning a force up did not move the column it governs, the + // measurement is not measuring it. + BenchOptions quick = new(Starts: 4, Frames: 2000, ReadableAfter: 600); + + IReadOnlyList rows = LayoutBench.Sweep( + GraphCorpus.Counter, + LayoutSettings.Defaults, + "repulsion", + [150_000.0, 600_000.0, 2_400_000.0], + (s, v) => s with { RepulsionStrength = v }, + quick); + + Console.WriteLine(LayoutBench.Table(rows)); + + Assert.IsLessThan(rows[1].MeanTightestGap, rows[0].MeanTightestGap, + $"more repulsion should leave the closest pair more room; {rows[0].MeanTightestGap:F1} then {rows[1].MeanTightestGap:F1}"); + Assert.IsLessThan(rows[2].MeanTightestGap, rows[1].MeanTightestGap, + $"and more again; {rows[1].MeanTightestGap:F1} then {rows[2].MeanTightestGap:F1}"); + Assert.IsLessThan(rows[2].MeanArea, rows[0].MeanArea, + $"and the graph should end up larger overall; {rows[0].MeanArea:F0} against {rows[2].MeanArea:F0}"); + } + + [TestMethod] + public void Compare_PutsNamedVariantsSideBySide() + { + BenchOptions quick = new(Starts: 3, Frames: 1200, ReadableAfter: 600); + + LayoutSettings withRepulsion = LayoutSettings.Defaults; + LayoutSettings without = LayoutSettings.Defaults; + without.RepulsionStrength = 0; + + IReadOnlyList rows = LayoutBench.Compare( + GraphCorpus.Counter, + quick, + ("with", withRepulsion), + ("without", without)); + + string table = LayoutBench.Table(rows); + Console.WriteLine(table); + + Assert.IsTrue(table.Contains("with", StringComparison.Ordinal), "the table should name each variant"); + Assert.IsTrue(table.Contains("tightGap", StringComparison.Ordinal), "and carry a header row"); + Assert.IsGreaterThan(rows[1].MeanTightestGap, rows[0].MeanTightestGap, + "and repulsion should be what leaves the closest pair its room"); + } + + [TestMethod] + public void Svg_DrawsEveryNodeAndLink_AndCallsOutOverlaps() + { + LayoutCore core = Arrange((1, 0, 0, 100, 50), (2, 60, 20, 100, 50)); + core.ResizeEdges(1); + core.Edges[0] = new EdgeRef + { + SourceIndex = 0, + TargetIndex = 1, + SourcePinOffset = new Vec2D(100, 25), + TargetPinOffset = new Vec2D(0, 25), + HasPinOffsets = 1, + }; + + string svg = LayoutSvg.Render(core, "a caption"); + + Assert.IsTrue(svg.StartsWith("", StringComparison.Ordinal), "and a closed one"); + Assert.AreEqual(3, CountOf(svg, " + /// What each corpus graph is expected to settle into under the defaults. + /// + /// The graph. + /// Mean angle off horizontal it must stay under. + /// How many starts must read left-to-right within ten seconds. + private sealed record Expectation(BenchGraph Graph, double MaxEdgeAngle, int MinReadableStarts); + + /// + /// The quality gate: every graph in the corpus settles into something a reader could follow. + /// + /// + /// This is the test a layout change is expected to break if it makes things worse, and the reason + /// the corpus has four shapes rather than one — a change that helps a wide fan-in can hurt a deep + /// chain, and only running both says so. The thresholds are current behaviour with headroom, not + /// targets: they are here to catch a regression, not to pin the numbers a particular tuning + /// happens to produce. + /// + /// Two of them are loose for a reason worth knowing, because it is a real defect and not a quirk + /// of the measurement. is a plain twelve-node chain, the shape that + /// most obviously wants to be a horizontal row, and it settles at about 53 degrees with only two + /// starts in six reading left to right. It is not a question of settling time — 4000, 12000 and + /// 30000 frames all land on the same 52.6 — it is gravity. Sweeping + /// over the same six starts: + /// + /// + /// gravity=0 angle 0.6 readable 5/6 + /// gravity=10 angle 1.9 readable 5/6 + /// gravity=50 angle 52.9 readable 2/6 (the default) + /// gravity=200 angle 58.4 readable 0/6 + /// + /// + /// Pulling every body towards one centre folds a long chain into a coil, and the directional bias + /// that is supposed to order it left-to-right does not undo that — raising the bias makes it worse + /// (62.7 degrees at bias 2), because ordering the pairs says nothing about the shape of the whole. + /// is a chain too and coils the same way. Fixing it is a + /// change to gravity, not to this gate, so the thresholds record where it stands. + /// + /// + [TestMethod] + public void Corpus_SettlesIntoAReadableShape_UnderTheDefaults() + { + BenchOptions shape = new(Starts: 6, Frames: 4000, ReadableAfter: 600); + + Expectation[] expectations = + [ + new(GraphCorpus.Counter, MaxEdgeAngle: 40.0, MinReadableStarts: 5), + new(GraphCorpus.Chain, MaxEdgeAngle: 60.0, MinReadableStarts: 1), + new(GraphCorpus.FanIn, MaxEdgeAngle: 45.0, MinReadableStarts: 4), + new(GraphCorpus.MixedSizes, MaxEdgeAngle: 58.0, MinReadableStarts: 1), + ]; + + List rows = []; + foreach (Expectation expectation in expectations) + { + rows.Add(LayoutBench.Run(expectation.Graph, LayoutSettings.Defaults, expectation.Graph.Name, shape)); + } + + Console.WriteLine(LayoutBench.Table(rows)); + + for (int i = 0; i < rows.Count; i++) + { + BenchResult row = rows[i]; + Expectation expectation = expectations[i]; + string graph = expectation.Graph.Name; + + // These two hold for every shape: a settled graph never leaves bodies drawn over one + // another, and never leaves a pair with no room between them. + Assert.AreEqual(0.0, row.WorstOverlap, 0.5, + $"{graph}: no start should be left with bodies drawn over one another"); + Assert.IsGreaterThan(10.0, row.MeanTightestGap, + $"{graph}: the closest pair should have real room between them; it had {row.MeanTightestGap:F1}"); + + Assert.IsLessThan(expectation.MaxEdgeAngle, row.MeanEdgeAngle, + $"{graph}: mean edge angle should stay under {expectation.MaxEdgeAngle:F0} degrees; it was {row.MeanEdgeAngle:F1}"); + Assert.IsGreaterThanOrEqualTo(expectation.MinReadableStarts, row.ReadableStarts, + $"{graph}: at least {expectation.MinReadableStarts} of {row.Starts} starts should read left-to-right " + + $"within ten seconds; {row.ReadableStarts} did"); + } + } + + /// Counts non-overlapping occurrences of a token. + /// Text to search. + /// Token to count. + private static int CountOf(string text, string token) + { + int count = 0; + int at = text.IndexOf(token, StringComparison.Ordinal); + while (at >= 0) + { + count++; + at = text.IndexOf(token, at + token.Length, StringComparison.Ordinal); + } + + return count; + } +} diff --git a/tests/ForceDirectedLayout.Tests/Bench/LayoutMetrics.cs b/tests/ForceDirectedLayout.Tests/Bench/LayoutMetrics.cs new file mode 100644 index 0000000..7ccf6ae --- /dev/null +++ b/tests/ForceDirectedLayout.Tests/Bench/LayoutMetrics.cs @@ -0,0 +1,262 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ForceDirectedLayout.Tests.Bench; + +using System; +using ktsu.ForceDirectedLayout; + +/// +/// What a settled layout measures: the numbers that say whether a reader could follow the graph. +/// +/// +/// No one of these is the answer, and that is the point of having them together. A graph can be +/// spread wide and still illegible because its links run under its nodes; it can have no crossings +/// and no overlaps and still be a tall column no one can read; and a collapse into a crushed ribbon +/// flatters both the area and the angle while being the worst outcome of the lot. Reading them as a +/// row is what stops a change that traded one for another from looking like an improvement. +/// +/// Width of the bounding box around every node. +/// Height of that box. +/// Mean angle off horizontal across the edges, in degrees, folded into 0-90. +/// Links drawn across a node they are not an end of, which a renderer hides. +/// Clear space between the closest pair of node boxes. +/// Mean clear space over every pair. +/// Deepest overlap between any two boxes; anything above zero is a defect. +/// Pairs of links meeting at a node whose far ends sit in the wrong order, so they cross. +/// Whether the simulation reported itself stable. +public readonly record struct LayoutMetrics( + double Width, + double Height, + double MeanEdgeAngle, + int LinksOverBodies, + double TightestClearGap, + double MeanClearGap, + double WorstOverlap, + int TwistedPairs, + bool Settled) +{ + /// Area of the bounding box, the coarsest measure of how much room a graph has. + public double Area => Width * Height; + + /// + /// True when the graph reads left to right with roughly horizontal edges, which is the shape a + /// node editor is trying to reach. + /// + public bool Readable => Width > Height && MeanEdgeAngle < 45.0; + + /// How many points along a link are tested against each node it might be hidden behind. + private const int LinkSamples = 40; + + /// Measures a core's current arrangement. + /// The core to measure. Its edges are expected to carry pin offsets. + public static LayoutMetrics Measure(LayoutCore core) + { + ArgumentNullException.ThrowIfNull(core); + + (double width, double height) = BoundingBox(core); + (double tightest, double mean, double worstOverlap) = GapStatistics(core); + + return new LayoutMetrics( + width, + height, + MeasureMeanEdgeAngle(core), + CountLinksOverBodies(core), + tightest, + mean, + worstOverlap, + CountTwistedPairs(core), + core.IsStable); + } + + /// Where an edge's two ends are drawn, in world space. + /// The core holding the edge. + /// Index of the edge. + private static (Vec2D From, Vec2D To) Ends(LayoutCore core, int e) + { + EdgeRef edge = core.Edges[e]; + BodyState source = core.Bodies[edge.SourceIndex]; + BodyState target = core.Bodies[edge.TargetIndex]; + + return edge.HasPinOffsets != 0 + ? (source.Position + edge.SourcePinOffset, target.Position + edge.TargetPinOffset) + : (source.Position + (source.Dimensions * 0.5), target.Position + (target.Dimensions * 0.5)); + } + + /// The box around every node. + /// The core to measure. + private static (double Width, double Height) BoundingBox(LayoutCore core) + { + if (core.BodyCount == 0) + { + return (0.0, 0.0); + } + + 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 (maxX - minX, maxY - minY); + } + + /// Mean angle off horizontal across the edges, folded into 0-90 degrees. + /// The core to measure. + private static double MeasureMeanEdgeAngle(LayoutCore core) + { + if (core.EdgeCount == 0) + { + return 0.0; + } + + double total = 0.0; + for (int e = 0; e < core.EdgeCount; e++) + { + (Vec2D from, Vec2D to) = Ends(core, e); + double angle = Math.Abs(Math.Atan2(to.Y - from.Y, to.X - from.X) * 180.0 / Math.PI); + total += angle > 90.0 ? 180.0 - angle : angle; + } + + return total / core.EdgeCount; + } + + /// + /// Counts the (link, node) pairs where a link is drawn across a node it is no end of. + /// + /// + /// Links render beneath node backgrounds, so such a link disappears for that node's width. The + /// path is approximated by the straight line between the pins, which is what the rendered curve + /// stays close to once the flattening force has done its work. + /// + /// The core to measure. + private static int CountLinksOverBodies(LayoutCore core) + { + int over = 0; + + for (int e = 0; e < core.EdgeCount; e++) + { + (Vec2D from, Vec2D to) = Ends(core, e); + + for (int b = 0; b < core.BodyCount; b++) + { + if (b == core.Edges[e].SourceIndex || b == core.Edges[e].TargetIndex) + { + continue; + } + + BodyState body = core.Bodies[b]; + for (int step = 1; step < LinkSamples; step++) + { + Vec2D at = Vec2D.Lerp(from, to, (double)step / LinkSamples); + if (at.X >= body.Position.X && at.X <= body.Position.X + body.Dimensions.X && + at.Y >= body.Position.Y && at.Y <= body.Position.Y + body.Dimensions.Y) + { + over++; + break; + } + } + } + } + + return over; + } + + /// + /// The tightest and mean clear space between node boxes, and the deepest overlap among them. + /// + /// + /// Clear space is measured the way repulsion measures it — between the two closest points on a + /// pair's boxes — so what this reports is the same quantity the force is working on, and the + /// tightest of them is the one pair a reader would call crowded. + /// + /// The core to measure. + private static (double Tightest, double Mean, double WorstOverlap) GapStatistics(LayoutCore core) + { + double tightest = double.MaxValue; + double total = 0.0; + double worstOverlap = 0.0; + int pairs = 0; + + for (int i = 0; i < core.BodyCount; i++) + { + for (int j = i + 1; j < core.BodyCount; j++) + { + BodyState a = core.Bodies[i]; + BodyState b = core.Bodies[j]; + + double betweenX = Math.Abs(b.Position.X + (b.Dimensions.X * 0.5) - a.Position.X - (a.Dimensions.X * 0.5)); + double betweenY = Math.Abs(b.Position.Y + (b.Dimensions.Y * 0.5) - a.Position.Y - (a.Dimensions.Y * 0.5)); + + double gapX = betweenX - ((a.Dimensions.X + b.Dimensions.X) * 0.5); + double gapY = betweenY - ((a.Dimensions.Y + b.Dimensions.Y) * 0.5); + + double gap = Math.Sqrt((Math.Max(gapX, 0.0) * Math.Max(gapX, 0.0)) + (Math.Max(gapY, 0.0) * Math.Max(gapY, 0.0))); + tightest = Math.Min(tightest, gap); + total += gap; + pairs++; + + if (gapX < 0.0 && gapY < 0.0) + { + worstOverlap = Math.Max(worstOverlap, Math.Min(-gapX, -gapY)); + } + } + } + + return pairs == 0 ? (0.0, 0.0, 0.0) : (tightest, total / pairs, worstOverlap); + } + + /// + /// Counts pairs of links meeting at a node whose far ends sit in the opposite vertical order to + /// the pins they meet at, which is exactly the arrangement in which the two are drawn crossing. + /// + /// The core to measure. + private static int CountTwistedPairs(LayoutCore core) + { + int twisted = 0; + + for (int i = 0; i < core.EdgeCount; i++) + { + for (int j = i + 1; j < core.EdgeCount; j++) + { + EdgeRef first = core.Edges[i]; + EdgeRef second = core.Edges[j]; + + (Vec2D firstFrom, Vec2D firstTo) = Ends(core, i); + (Vec2D secondFrom, Vec2D secondTo) = Ends(core, j); + + double atPins; + double atFarEnds; + + if (first.TargetIndex == second.TargetIndex && first.SourceIndex != second.SourceIndex) + { + atPins = firstTo.Y - secondTo.Y; + atFarEnds = firstFrom.Y - secondFrom.Y; + } + else if (first.SourceIndex == second.SourceIndex && first.TargetIndex != second.TargetIndex) + { + atPins = firstFrom.Y - secondFrom.Y; + atFarEnds = firstTo.Y - secondTo.Y; + } + else + { + continue; + } + + if (atPins * atFarEnds < 0.0) + { + twisted++; + } + } + } + + return twisted; + } +} diff --git a/tests/ForceDirectedLayout.Tests/Bench/LayoutSvg.cs b/tests/ForceDirectedLayout.Tests/Bench/LayoutSvg.cs new file mode 100644 index 0000000..176faa8 --- /dev/null +++ b/tests/ForceDirectedLayout.Tests/Bench/LayoutSvg.cs @@ -0,0 +1,171 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ForceDirectedLayout.Tests.Bench; + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using ktsu.ForceDirectedLayout; + +/// +/// Draws a settled layout to SVG, so it can be looked at rather than only measured. +/// +/// +/// Metrics catch what they were written to catch. A picture catches the rest — a graph that settled +/// into two clumps joined by one long link, a node parked inside a fan of edges it has nothing to do +/// with, a chain that folded back on itself — none of which any single number here names. Writing +/// SVG rather than rendering through the editor means no window, no GPU and no ImGui context, so it +/// works from any test on any machine. +/// +/// Links are drawn first and as the cubic the renderer actually draws, with its control points +/// offset horizontally by a quarter of the link's length, and nodes are drawn over them. That is the +/// real z-order, so a link that vanishes behind a node in this picture is a link that vanishes in the +/// editor — which is the defect counts. +/// +/// +public static class LayoutSvg +{ + /// Blank space left around the graph's bounding box. + private const double Margin = 40.0; + + /// Fraction of a link's length its bezier control points are offset by. + private const double ControlPointFraction = 0.25; + + /// Writes a core's current arrangement to an SVG file. + /// The layout to draw. + /// Where to write it. + /// Optional line of text drawn at the top, such as a metrics summary. + public static void Write(LayoutCore core, string path, string? caption = null) + { + ArgumentNullException.ThrowIfNull(core); + File.WriteAllText(path, Render(core, caption)); + } + + /// Renders a core's current arrangement as SVG markup. + /// The layout to draw. + /// Optional line of text drawn at the top, such as a metrics summary. + public static string Render(LayoutCore core, string? caption = null) + { + ArgumentNullException.ThrowIfNull(core); + + (double minX, double minY, double width, double height) = Viewport(core); + HashSet overlapping = OverlappingBodies(core); + + StringBuilder svg = new(); + svg.Append(Invariant($"")); + svg.Append(Invariant($"")); + + // Links first: the renderer draws them beneath the node backgrounds, so anything hidden here + // is hidden there too. + for (int e = 0; e < core.EdgeCount; e++) + { + (Vec2D from, Vec2D to) = Ends(core, e); + double offset = Math.Sqrt(((to.X - from.X) * (to.X - from.X)) + ((to.Y - from.Y) * (to.Y - from.Y))) * ControlPointFraction; + + svg.Append(Invariant($"")); + } + + for (int i = 0; i < core.BodyCount; i++) + { + BodyState body = core.Bodies[i]; + string fill = overlapping.Contains(i) ? "#5a2a2a" : "#2d2d30"; + string stroke = overlapping.Contains(i) ? "#e06c75" : "#5a5a5e"; + + svg.Append(Invariant($"")); + svg.Append(Invariant( + $"{body.Id}")); + } + + if (!string.IsNullOrEmpty(caption)) + { + svg.Append(Invariant( + $"{Escape(caption)}")); + } + + svg.Append(""); + return svg.ToString(); + } + + /// The area to draw, being the graph's bounding box plus a margin. + /// The layout to measure. + private static (double MinX, double MinY, double Width, double Height) Viewport(LayoutCore core) + { + if (core.BodyCount == 0) + { + return (0.0, 0.0, 1.0, 1.0); + } + + 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 (minX - Margin, minY - Margin, maxX - minX + (Margin * 2), maxY - minY + (Margin * 2)); + } + + /// Indices of every body drawn over another, so the picture can call them out. + /// The layout to inspect. + private static HashSet OverlappingBodies(LayoutCore core) + { + HashSet overlapping = []; + + for (int i = 0; i < core.BodyCount; i++) + { + for (int j = i + 1; j < core.BodyCount; j++) + { + BodyState a = core.Bodies[i]; + BodyState b = core.Bodies[j]; + + bool apart = + a.Position.X + a.Dimensions.X <= b.Position.X || + b.Position.X + b.Dimensions.X <= a.Position.X || + a.Position.Y + a.Dimensions.Y <= b.Position.Y || + b.Position.Y + b.Dimensions.Y <= a.Position.Y; + + if (!apart) + { + overlapping.Add(i); + overlapping.Add(j); + } + } + } + + return overlapping; + } + + /// Where an edge's two ends are drawn, in world space. + /// The core holding the edge. + /// Index of the edge. + private static (Vec2D From, Vec2D To) Ends(LayoutCore core, int e) + { + EdgeRef edge = core.Edges[e]; + BodyState source = core.Bodies[edge.SourceIndex]; + BodyState target = core.Bodies[edge.TargetIndex]; + + return edge.HasPinOffsets != 0 + ? (source.Position + edge.SourcePinOffset, target.Position + edge.TargetPinOffset) + : (source.Position + (source.Dimensions * 0.5), target.Position + (target.Dimensions * 0.5)); + } + + /// Formats with the invariant culture, so a comma decimal separator cannot corrupt the markup. + /// An interpolated string to format. + private static string Invariant(FormattableString text) => text.ToString(CultureInfo.InvariantCulture); + + /// Escapes the few characters that would otherwise end a text element early. + /// Text to escape. + private static string Escape(string text) => + text.Replace("&", "&", StringComparison.Ordinal) + .Replace("<", "<", StringComparison.Ordinal) + .Replace(">", ">", StringComparison.Ordinal); +} diff --git a/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs b/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs index c09f30b..f8a17f4 100644 --- a/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs +++ b/tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs @@ -6,6 +6,7 @@ namespace ktsu.ForceDirectedLayout.Tests; using System.Collections.Generic; using System.Linq; using ktsu.ForceDirectedLayout; +using ktsu.ForceDirectedLayout.Tests.Bench; using Microsoft.VisualStudio.TestTools.UnitTesting; [TestClass] @@ -403,91 +404,6 @@ private static ForceDirectedLayout CreatePinnedLayout(Phys Settings = settings, }; - /// A node in the benchmark graph, sized as the editor draws it. - private sealed record Box(int Id, double W, double H, int InputRows, int OutputRows); - - /// An edge that knows which row it leaves and enters on. - private sealed record Wire(int From, int FromRow, int To, int ToRow); - - /// - /// The Counter document as the editor lays it out: twenty nodes, twenty edges, five levels deep. - /// - private static (List Bodies, List Edges) CounterGraph(double spread) - { - // id, width, height, input rows, output rows - Box[] boxes = - [ - new(1, 60, 50, 0, 1), new(2, 115, 62, 1, 1), // 0 -> var count - new(3, 60, 50, 0, 1), new(4, 115, 62, 1, 1), // 1 -> var step - new(5, 110, 50, 0, 1), // param amount - new(6, 75, 50, 0, 1), new(7, 75, 50, 0, 1), new(8, 75, 50, 0, 1), new(9, 75, 50, 0, 1), - new(10, 90, 75, 2, 1), // binary + (Add) - new(11, 90, 70, 2, 1), // assign - new(12, 105, 60, 1, 1), // return (Add) - new(13, 118, 180, 7, 1), // function Add - new(14, 75, 50, 0, 1), new(15, 75, 50, 0, 1), - new(16, 90, 75, 2, 1), // binary + (Next) - new(17, 115, 62, 1, 1), // var result - new(18, 105, 60, 1, 1), // return (Next) - new(19, 118, 160, 6, 1), // function Next - new(20, 117, 160, 6, 1), // class Counter - ]; - - Wire[] wires = - [ - new(1, 0, 2, 0), new(2, 0, 20, 0), - new(3, 0, 4, 0), new(4, 0, 20, 1), - new(5, 0, 13, 0), - new(6, 0, 11, 0), new(7, 0, 10, 0), new(8, 0, 10, 1), new(10, 0, 11, 1), - new(11, 0, 13, 3), new(9, 0, 12, 0), new(12, 0, 13, 4), new(13, 0, 20, 2), - new(14, 0, 16, 0), new(15, 0, 16, 1), new(16, 0, 17, 0), new(17, 0, 19, 2), - new(18, 0, 19, 3), new(19, 0, 20, 3), - new(9, 0, 18, 0), - ]; - - Dictionary byId = boxes.ToDictionary(b => b.Id); - List bodies = []; - int seed = 0; - foreach (Box b in boxes) - { - // Scattered start, so the layout has to do the work rather than inherit an answer. - seed = ((seed * 1103515245) + 12345) & 0x7fffffff; - double x = seed % 1000 * spread; - seed = ((seed * 1103515245) + 12345) & 0x7fffffff; - double y = seed % 1000 * spread; - bodies.Add(Body(b.Id, x, y, b.W, b.H)); - } - - // A row's pin sits a header down plus its own row height, which is what ImNodes produces. - static double RowY(Box b, int row) => Math.Min(28.0 + (row * 21.0) + 10.0, b.H - 8.0); - - List edges = [.. wires.Select(w => new PinnedEdge( - w.From, w.To, - new Vec2D(byId[w.From].W, RowY(byId[w.From], byId[w.From].InputRows + w.FromRow)), - new Vec2D(0, RowY(byId[w.To], w.ToRow))))]; - - return (bodies, edges); - } - - /// Mean angle off horizontal across a graph's edges, and its bounding box. - private static (double Width, double Height, double MeanAngle) Shape(List bodies, List edges) - { - double width = bodies.Max(b => b.Position.X + b.Dimensions.X) - bodies.Min(b => b.Position.X); - double height = bodies.Max(b => b.Position.Y + b.Dimensions.Y) - bodies.Min(b => b.Position.Y); - - Dictionary byId = bodies.ToDictionary(b => b.Id); - double total = 0; - foreach (PinnedEdge e in edges) - { - Vec2D from = byId[e.SourceId].Position + e.SourcePin; - Vec2D to = byId[e.TargetId].Position + e.TargetPin; - double angle = Math.Abs(Math.Atan2(to.Y - from.Y, to.X - from.X) * 180.0 / Math.PI); - total += angle > 90 ? 180 - angle : angle; - } - - return (width, height, total / edges.Count); - } - /// /// Tests that a graph the size of a small class reaches a readable shape within the first few /// seconds, rather than only after a minute and a half of settling. @@ -502,61 +418,17 @@ private static (double Width, double Height, double MeanAngle) Shape(List bodies, List edges) = CounterGraph(0.05); - ForceDirectedLayout layout = CreatePinnedLayout(new PhysicsSettings { Enabled = true }); + // Over several starts rather than one: the simulation is chaotic, so which local minimum a + // single arrangement falls into says nothing about how fast the layout gets there in general. + BenchResult result = LayoutBench.Run( + GraphCorpus.Counter, + LayoutSettings.Defaults, + "counter", + new BenchOptions(Starts: 6, Frames: 600, ReadableAfter: 600)); - for (int i = 0; i < 600; i++) - { - layout.Step(bodies, edges, 0.016); - } - - (double width, double height, double meanAngle) = Shape(bodies, edges); - - Assert.IsTrue(width > height, - $"A left-to-right graph should be wider than it is tall by now; it is {width:F0} x {height:F0}."); - Assert.IsTrue(meanAngle < 45.0, - $"Its edges should be nearer horizontal than vertical by now; mean angle is {meanAngle:F1} degrees."); - } - - /// - /// Counts the (link, body) pairs where a link is drawn across a body it is not an end of. - /// - /// - /// Links are rendered beneath node backgrounds, so a link crossing a body it has nothing to do with - /// disappears for that body's width. The link's path is approximated by the straight line between its - /// pins, which is what the rendered curve stays close to once the flattening force has done its work. - /// - private static int LinksOverBodies(List bodies, List edges) - { - Dictionary byId = bodies.ToDictionary(b => b.Id); - int over = 0; - - foreach (PinnedEdge edge in edges) - { - Vec2D from = byId[edge.SourceId].Position + edge.SourcePin; - Vec2D to = byId[edge.TargetId].Position + edge.TargetPin; - - foreach (TestBody body in bodies) - { - if (body.Id == edge.SourceId || body.Id == edge.TargetId) - { - continue; - } - - for (int step = 1; step < 40; step++) - { - Vec2D at = Vec2D.Lerp(from, to, step / 40.0); - if (at.X >= body.Position.X && at.X <= body.Position.X + body.Dimensions.X && - at.Y >= body.Position.Y && at.Y <= body.Position.Y + body.Dimensions.Y) - { - over++; - break; - } - } - } - } - - return over; + Assert.IsGreaterThanOrEqualTo(5, result.ReadableStarts, + $"A left-to-right graph should be wider than it is tall with near-horizontal edges by now; " + + $"{result.ReadableStarts} of {result.Starts} starts were, at a mean angle of {result.MeanEdgeAngle:F1} degrees."); } /// @@ -602,29 +474,6 @@ private static int TwistedPairs(List bodies, List edges) return twisted; } - /// Deepest rectangle overlap between any two bodies. - private static double WorstOverlap(List bodies) - { - double worst = 0; - for (int i = 0; i < bodies.Count; i++) - { - for (int j = i + 1; j < bodies.Count; j++) - { - double acrossX = Math.Min(bodies[i].Position.X + bodies[i].Dimensions.X, bodies[j].Position.X + bodies[j].Dimensions.X) - - Math.Max(bodies[i].Position.X, bodies[j].Position.X); - double acrossY = Math.Min(bodies[i].Position.Y + bodies[i].Dimensions.Y, bodies[j].Position.Y + bodies[j].Dimensions.Y) - - Math.Max(bodies[i].Position.Y, bodies[j].Position.Y); - - if (acrossX > 0 && acrossY > 0) - { - worst = Math.Max(worst, Math.Min(acrossX, acrossY)); - } - } - } - - return worst; - } - /// /// Tests that repulsion is still what spreads a graph out, now that an overlap pass keeps bodies off /// one another and an untwisting force reorders their far ends. @@ -632,9 +481,7 @@ private static double WorstOverlap(List bodies) /// /// The overlap pass only guarantees bodies do not sit on top of each other; it creates no room beyond /// that, and untwisting only says which way round two of them go. Without repulsion a settled graph - /// collapses to about a third of its area, and the links then have nowhere to run but across the - /// bodies: measured over the graph below, six times as many links are drawn over a body they are not - /// an end of. + /// collapses, and the links then have nowhere to run but across the bodies. /// /// Neither shape nor edge angle can say this. Before there was an untwisting force the collapse was /// into a tall column of near-vertical links, and both did; with one the collapse is into a flat @@ -642,33 +489,35 @@ private static double WorstOverlap(List bodies) /// for it - everything is simply drawn on top of everything - so what is asserted here is the room /// itself, and what the want of it does to the links. /// + /// + /// Measured over several starts rather than one. On a single arrangement this claim is true on + /// average and unreliable in particular: the settled area of one start swings far enough with the + /// repulsion strength that the same assertion passed at 1,200,000 and 600,000, failed at 800,000, + /// and passed again at 400,000 - chaos, not a threshold. + /// /// [TestMethod] public void Repulsion_IsWhatSpreadsAGraphOut() { - (List withBodies, List withEdges) = CounterGraph(0.05); - ForceDirectedLayout with = CreatePinnedLayout(new PhysicsSettings { Enabled = true }); + BenchOptions shape = new(Starts: 8, Frames: 6000, ReadableAfter: 600); - (List withoutBodies, List withoutEdges) = CounterGraph(0.05); - ForceDirectedLayout without = CreatePinnedLayout(new PhysicsSettings { Enabled = true, RepulsionStrength = 0 }); + LayoutSettings without = LayoutSettings.Defaults; + without.RepulsionStrength = 0; - for (int i = 0; i < 6000; i++) - { - with.Step(withBodies, withEdges, 0.016); - without.Step(withoutBodies, withoutEdges, 0.016); - } - - (double withWidth, double withHeight, double _) = Shape(withBodies, withEdges); - (double withoutWidth, double withoutHeight, double _) = Shape(withoutBodies, withoutEdges); + IReadOnlyList rows = LayoutBench.Compare( + GraphCorpus.Counter, + shape, + ("with", LayoutSettings.Defaults), + ("without", without)); - double withArea = withWidth * withHeight; - double withoutArea = withoutWidth * withoutHeight; + BenchResult with = rows[0]; + BenchResult none = rows[1]; - Assert.IsTrue(withArea > withoutArea * 2.0, - $"Repulsion should leave the graph far roomier; with {withArea:F0}, without {withoutArea:F0}."); - Assert.IsTrue(LinksOverBodies(withBodies, withEdges) * 3 < LinksOverBodies(withoutBodies, withoutEdges), - $"Without repulsion far more links should be drawn over bodies; with {LinksOverBodies(withBodies, withEdges)}, " + - $"without {LinksOverBodies(withoutBodies, withoutEdges)}."); + Assert.IsGreaterThan(none.MeanArea * 2.0, with.MeanArea, + $"Repulsion should leave the graph far roomier; with {with.MeanArea:F0}, without {none.MeanArea:F0}."); + Assert.IsLessThan(none.MeanLinksOverBodies, with.MeanLinksOverBodies * 3, + $"Without repulsion far more links should be drawn over bodies; with {with.MeanLinksOverBodies:F1}, " + + $"without {none.MeanLinksOverBodies:F1}."); } /// @@ -750,33 +599,21 @@ public void Untwisting_ReducesCrossingsWithoutLeavingBodiesOverlapping() // Several starting arrangements, because which local minimum one start happens to land in says // nothing: the claim is about the shape of a settled graph in general, so it is measured the way // it was established. - int withTwists = 0; - int withoutTwists = 0; - double worstOverlap = 0; - - foreach (double spread in new[] { 0.05, 0.2, 0.35, 0.5, 0.7, 1.0 }) - { - (List withBodies, List withEdges) = CounterGraph(spread); - ForceDirectedLayout with = CreatePinnedLayout(new PhysicsSettings { Enabled = true }); + BenchOptions shape = new(Starts: 6, Frames: 4000, ReadableAfter: 600); - (List withoutBodies, List withoutEdges) = CounterGraph(spread); - ForceDirectedLayout without = CreatePinnedLayout( - new PhysicsSettings { Enabled = true, LinkUntwistStrength = 0 }); + LayoutSettings without = LayoutSettings.Defaults; + without.LinkUntwistStrength = 0; - for (int i = 0; i < 4000; i++) - { - with.Step(withBodies, withEdges, 0.016); - without.Step(withoutBodies, withoutEdges, 0.016); - } - - withTwists += TwistedPairs(withBodies, withEdges); - withoutTwists += TwistedPairs(withoutBodies, withoutEdges); - worstOverlap = Math.Max(worstOverlap, WorstOverlap(withBodies)); - } + IReadOnlyList rows = LayoutBench.Compare( + GraphCorpus.Counter, + shape, + ("with", LayoutSettings.Defaults), + ("without", without)); - Assert.IsTrue(withTwists < withoutTwists, - $"Untwisting should leave fewer crossed pairs across the six starts; with {withTwists}, without {withoutTwists}."); - Assert.AreEqual(0.0, worstOverlap, 0.5, + Assert.IsLessThan(rows[1].MeanTwistedPairs, rows[0].MeanTwistedPairs, + $"Untwisting should leave fewer crossed pairs across the starts; with {rows[0].MeanTwistedPairs:F1}, " + + $"without {rows[1].MeanTwistedPairs:F1}."); + Assert.AreEqual(0.0, rows[0].WorstOverlap, 0.5, "and no start should be left with bodies drawn over one another"); }