Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string?, string>?` — 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.
Expand All @@ -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
Expand Down Expand Up @@ -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/<Demo>.UITests/`, built on
Expand Down
2 changes: 2 additions & 0 deletions ForceDirectedLayout/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
156 changes: 156 additions & 0 deletions tests/ForceDirectedLayout.Tests/Bench/BenchGraph.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>A node in a benchmark graph, sized the way a node editor would draw it.</summary>
/// <param name="Id">Stable identifier, unique within the graph.</param>
/// <param name="Width">Drawn width.</param>
/// <param name="Height">Drawn height.</param>
/// <param name="InputRows">How many input pin rows it has, which is the row its outputs start at.</param>
public sealed record BenchNode(int Id, double Width, double Height, int InputRows);

/// <summary>An edge that knows which pin row it leaves and which it arrives at.</summary>
/// <param name="From">Id of the source node.</param>
/// <param name="FromRow">Which of the source's output rows it leaves from, counted from the first.</param>
/// <param name="To">Id of the target node.</param>
/// <param name="ToRow">Which of the target's input rows it arrives at.</param>
public sealed record BenchEdge(int From, int FromRow, int To, int ToRow);

/// <summary>
/// A graph to measure a layout against: nodes with real sizes, and edges that attach at real pins.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class BenchGraph
{
/// <summary>Height of a node's header, above its first pin row.</summary>
private const double HeaderHeight = 28.0;

/// <summary>Vertical pitch from one pin row to the next.</summary>
private const double RowPitch = 21.0;

/// <summary>Offset from a row's top to the pin itself.</summary>
private const double RowCentre = 10.0;

/// <summary>Clearance kept between the last usable pin position and a node's bottom edge.</summary>
private const double BottomMargin = 8.0;

/// <summary>Constructs a graph from its nodes and edges.</summary>
/// <param name="name">What to call it in a report.</param>
/// <param name="nodes">The nodes, each with a distinct id.</param>
/// <param name="edges">The edges between them.</param>
public BenchGraph(string name, IReadOnlyList<BenchNode> nodes, IReadOnlyList<BenchEdge> edges)
{
ArgumentNullException.ThrowIfNull(nodes);
ArgumentNullException.ThrowIfNull(edges);

Name = name;
Nodes = nodes;
Edges = edges;

Dictionary<int, int> indexById = [];
for (int i = 0; i < nodes.Count; i++)
{
indexById[nodes[i].Id] = i;
}
IndexById = indexById;
}

/// <summary>What to call this graph in a report.</summary>
public string Name { get; }

/// <summary>The nodes, in the order they are submitted to a core.</summary>
public IReadOnlyList<BenchNode> Nodes { get; }

/// <summary>The edges between them.</summary>
public IReadOnlyList<BenchEdge> Edges { get; }

/// <summary>Body index of each node id, matching the order <see cref="Nodes"/> is submitted in.</summary>
public IReadOnlyDictionary<int, int> IndexById { get; }

/// <summary>
/// Where a pin sits on a node, relative to the node's own origin.
/// </summary>
/// <param name="node">The node the pin belongs to.</param>
/// <param name="row">Which row, counted from the node's first input row.</param>
/// <param name="onRight">True for an output pin, on the node's right edge.</param>
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);
}

/// <summary>
/// Builds a core holding this graph, scattered into one particular starting arrangement.
/// </summary>
/// <param name="settings">Settings for the core; <see cref="LayoutSettings.Enabled"/> is forced on.</param>
/// <param name="seed">Chooses the arrangement. The same seed always gives the same one.</param>
/// <param name="spread">
/// 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.
/// </param>
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;
}

/// <summary>
/// One coordinate of the scatter, from a linear congruential generator.
/// </summary>
/// <remarks>
/// Deliberately not <see cref="Random"/>: 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.
/// </remarks>
/// <param name="state">Generator state, advanced by the call.</param>
/// <param name="spread">Scatter reach, as a fraction of a thousand units.</param>
private static double NextScatter(ref int state, double spread)
{
state = ((state * 1103515245) + 12345) & 0x7fffffff;
return state % 1000 * spread;
}
}
Loading