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
39 changes: 38 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ This is the **ktsu ImGui Suite**, a collection of .NET libraries for building De
- **ImGui.Color** (`ktsu.ImGui.Color`) - Bridge between `ktsu.Semantics.Color` and ImGui. Colors are held as the semantic `Color` (linear) and `Srgb` types and converted only at the ImGui seam: `ColorImGuiExtensions` (`ToImColor`/`FromImColor`, `ToImGuiVector4`, `ToImGuiU32`) and `SrgbImGuiExtensions` (`Srgb` → `ImColor`/`ImGuiVector4`/`ImU32`, packed directly with no linear round-trip). The `ImColor` and `Srgb` `ToImGuiU32` apply the global style alpha like `ImGui.GetColorU32`; the linear `Color.ToImGuiU32` is a pure pack matching `ColorConvertFloat4ToU32`. `ImColor` extension operations: adjustments (lighten/darken, saturate/desaturate, hue offset, grayscale, invert, alpha), analysis (relative luminance, contrast ratio, perceptual distance), and contrast heuristics (`MostReadableTextColor`, `AdjustForSufficientContrast`). All color math delegates to `ktsu.Semantics.Color`. (There is no `ImColor` factory class — construct via `Color`/`Srgb` and convert.)
- **ImGui.Styler** (`ktsu.ImGui.Styler`) - Theming system with 50+ built-in themes, scoped styling, Button.Alignment, Text.Color semantic colors, Indent utilities, Alignment helpers, theme-aware color palette (`Palette`, e.g. `Palette.Basic.Red`, `Palette.Semantic.Error`), and interactive theme browser. Color construction and manipulation live in `ImGui.Color`.
- **NodeGraph** (`ktsu.NodeGraph`) - UI-agnostic attribute-based node graph metadata: `[Node]`, `[InputPin]`, `[OutputPin]`, `[NodeExecute]`, `[NodeBehavior]`, pin type utilities
- **ForceDirectedLayout** (`ktsu.ForceDirectedLayout`) - Renderer-agnostic graph layout simulation, with no UI dependency and no runtime package dependencies. Bodies repel across the clear space between their bounding boxes (not between their centres — see [Layout benchmarking](#layout-benchmarking)), edges pull like springs between the pins they actually attach at, gravity holds the graph together, edges are pulled towards horizontal, and an overlap pass separates any boxes left drawn over one another. Three surfaces over one `LayoutCore`: a generic facade over your own types, an id-based `ForceLayout` for bulk POD submission, and the flat core. Also published as a Native AOT shared library with a C ABI. `ImGui.NodeEditor` is one consumer.
- **ForceDirectedLayout** (`ktsu.ForceDirectedLayout`) - Renderer-agnostic graph layout simulation, with no UI dependency and no runtime package dependencies. Bodies repel across the clear space between their bounding boxes (not between their centres — see [Layout benchmarking](#layout-benchmarking)), edges pull like springs between the pins they actually attach at, gravity holds the graph together, edges are pulled towards horizontal, an overlap pass separates any boxes left drawn over one another, and a recentring pass slides the whole arrangement so its drawn box sits on the world origin (see [Placement is not cohesion](#placement-is-not-cohesion)). Three surfaces over one `LayoutCore`: a generic facade over your own types, an id-based `ForceLayout` for bulk POD submission, and the flat core. Also published as a Native AOT shared library with a C ABI. `ImGui.NodeEditor` is one consumer.
- **ImGui.NodeEditor** (`ktsu.ImGui.NodeEditor`) - ImNodes-based visual node editor with `NodeEditorEngine`, `AttributeBasedNodeFactory`, physics-based layout, `NodeEditorRenderer`, `NodeEditorInputHandler`. `PhysicsSettingsPanel.Draw(ref PhysicsSettings)` draws every layout setting grouped by force and captioned, and `DrawDiagnostics(engine)` the live energy and settled state, so a consuming application gets the whole tuning surface rather than reimplementing a subset of it. ImNodes has no zoom of its own, so `NodeEditorRenderer.Zoom` supplies one and `FitToView` centres a graph and picks the zoom it fits at; the engine's positions and sizes stay at their own scale throughout, since that is the space the layout's lengths are measured in. Hovering is answered by the renderer: `HighlightLinksOnNodeHover` (on) colours the links meeting the hovered node, `HighlightDownstreamOnNodeHover` (off) also colours everything that node's value reaches, and `DrawHoveredLinkOnTop` (on) redraws the hovered link over the nodes ImNodes drew on top of it. See [Hover highlighting](#hover-highlighting) below. How many links a pin accepts is the pin's own business: `Pin.AllowsMultipleConnections` defaults to many for an output and one for an input, `[InputPin(AllowMultipleConnections = true)]` / `[OutputPin(AllowMultipleConnections = false)]` override it through the factory, and `NodeEditorEngine.SetPinAllowsMultipleConnections` sets it directly. `GetOutgoingLinks`, `GetIncomingLinks`, `GetDownstream` and `GetUpstream` walk the graph
- **ImGui.Markdown** (`ktsu.ImGui.Markdown`) - CommonMark markdown renderer built on Markdig (pipe tables, task lists, autolinks), layered on `ImGui.Color` only, with no dependency on `ImGui.App`. Static `ImGuiMarkdown.Render(string, MarkdownConfig?)` parses with an internal source-keyed cache; `MarkdownDocument` parses once for hot render paths. `MarkdownConfig` exposes `FontResolver`, `OnLinkClicked`, `ImageResolver`, `HeadingScales`, `WrapWidth`, `ListIndentPixels`, `ParagraphSpacingPixels`, and `LinkColor`. Heading sizes derive from the live font size, so DPI and `ImGuiApp.GlobalScale` are respected automatically. Bold/italic use real glyphs when the host app registers named font variants via `FontResolver`, otherwise faux styling (faux-bold double-draw, faux-italic renders upright). Fenced and indented code blocks go to `MarkdownConfig.CodeBlockRenderer` (`Action<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 Down Expand Up @@ -615,6 +615,43 @@ Four things to know before changing any of it:
unobservable because flattening decides that geometry outright. Their fixtures now name every input
they rely on.

### Placement is not cohesion

Gravity holds a graph together; it does **not** decide where the graph sits. A separate pass does
that, and the split is not incidental — the two obvious ways of merging them both fail, in ways worth
knowing before touching either.

Gravity pulls every body the same amount whichever side of the target it sits and however far out.
Summed over a graph that is a step function of position: it counts bodies rather than measuring them,
so anywhere the counts balance it is exactly zero and nothing holds the graph anywhere at all. A
twelve-node chain settled 79 units to one side and stayed; pushed 600 the other way it came to rest 79
units to the **other** side — the same distance out, on whichever side it arrived from, because both
are edges of the same dead band. And where the counts do balance is the median of the body centres,
which for a document with a dense cluster of literals on one side and a few large functions on the
other is nowhere near the middle of what is drawn: 200 units apart on `GraphCorpus.Counter`.

Making gravity proportional to distance fixes both of those and costs something worse. A body further
out is then pulled harder, so wide nodes are squeezed closer together than narrow ones and settled
spacing depends on node size again — which is precisely what measuring repulsion across clear space
rather than between centres was for. Measured, a 400-wide pair settled 160 apart against a 60-wide
pair's 224, and `SettledPairs_KeepTheSameClearSpace_WhateverTheirSize` fails.

So `LayoutCore.RecentreOnOrigin` slides the whole arrangement instead, positionally, after
integration. Every body moves by the same vector, so no distance between any two of them changes.
Three details:

- **It is positional, not a force.** As a force it was clamped per body by `MaxForce`, and a body
already at the ceiling lost its share while its neighbour kept theirs — which reshaped the graph,
the one thing the design exists to avoid.
- **It stands down when any body is pinned or frozen**, since whoever pinned it is saying where the
graph goes.
- **It is gated on `OriginAnchorWeight`**, so a test isolating one force should set that to zero or a
position it asserts will include the slide.

Measured over the corpus: mean offset from the origin 62.7 → 5.4 units, worst 172 → 19.5, with the
corpus score unchanged (1.194 → 1.166, inside noise). `RecentringTests` covers it, and a re-sweep of
all fifteen settings afterwards moved none of them — `GravityStrength` included, which stays at 50.

### Demo UI tests

Each example has a headless UI test project under `tests/<Demo>.UITests/`, built on
Expand Down
81 changes: 81 additions & 0 deletions ForceDirectedLayout/LayoutCore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@

ApplyDirectionalConstraints();
SeparateOverlaps();
RecentreOnOrigin();
}

double energy = 0.0;
Expand Down Expand Up @@ -504,7 +505,7 @@
/// <param name="next">The chain to follow, indexed by edge.</param>
/// <param name="sharedAtTarget">True when the list is of edges arriving, false when leaving.</param>
/// <param name="strength">Force per unit of vertical swap still to be made.</param>
private void UntwistSharedEnds(int first, int[] next, bool sharedAtTarget, double strength)

Check warning on line 508 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 24 to the 15 allowed.

Check warning on line 508 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 24 to the 15 allowed.

Check warning on line 508 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 24 to the 15 allowed.

Check warning on line 508 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 24 to the 15 allowed.
{
for (int a = first; a >= 0; a = next[a])
{
Expand Down Expand Up @@ -704,7 +705,7 @@
/// and the pair comes to rest still overlapping, just less.
/// </para>
/// </remarks>
private void SeparateOverlaps()

Check warning on line 708 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 47 to the 15 allowed.

Check warning on line 708 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 47 to the 15 allowed.

Check warning on line 708 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 47 to the 15 allowed.

Check warning on line 708 in ForceDirectedLayout/LayoutCore.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 47 to the 15 allowed.
{
double margin = Settings.OverlapMargin;
if (margin <= 0)
Expand Down Expand Up @@ -804,6 +805,17 @@
}
}

/// <summary>
/// The fraction of its remaining offset from <see cref="WorldOrigin"/> the arrangement is slid back
/// each substep, before <see cref="LayoutSettings.OriginAnchorWeight"/> scales it.
/// </summary>
/// <remarks>
/// Not a setting, because it does not change where a graph comes to rest — only how quickly it gets
/// there. The resting place is the origin either way. Clamped to one so the graph can close the
/// offset but never travel past it, whatever weight is asked for.
/// </remarks>
private const double RecentringRate = 0.25;

private void CalculateGravityForces()
{
if (bodyCount == 0)
Expand Down Expand Up @@ -835,6 +847,75 @@
}
}

/// <summary>
/// Slide the whole arrangement, as one piece, towards having its drawn bounding box centred on
/// <see cref="WorldOrigin"/>.
/// </summary>
/// <remarks>
/// Gravity cannot do this job, and it is worth being precise about why, because the obvious repair
/// makes things worse.
/// <para>
/// Gravity pulls each body the same amount whichever side of the target it sits and however far out
/// it is. Summed over the graph that is a step function of position: it counts bodies rather than
/// measuring them, so anywhere the counts happen to balance it is exactly zero and nothing holds
/// the graph anywhere at all. A twelve-node chain settles 79 units to one side and stays; pushed
/// 600 the other way it comes to rest 79 units to the *other* side, the same distance out, because
/// both are edges of the same dead band. And where the counts do balance is the median of the body
/// centres, which for a document with a dense cluster of literals on one side and a few large
/// functions on the other is nowhere near the middle of what is drawn — 200 units apart on the
/// corpus's Counter graph.
/// </para>
/// <para>
/// Making gravity proportional to distance fixes both of those and costs something worse: a body
/// further from the target is then pulled harder, so a pair of wide nodes is squeezed closer
/// together than a pair of narrow ones, and settled spacing depends on node size again — which is
/// the whole thing measuring repulsion across clear space rather than between centres was for.
/// Measured, a 400-wide pair settled 160 apart against a 60-wide pair's 224.
/// </para>
/// <para>
/// So placement is separated from cohesion instead. This force is identical on every body, which
/// means it cannot change any distance between them: it can only slide the whole arrangement, and
/// it slides it until the box a reader sees is centred where it should be. Gravity is left to do
/// the one thing it is good at, which is holding the graph together.
/// </para>
/// </remarks>
private void RecentreOnOrigin()
{
double weight = Settings.OriginAnchorWeight;
if (bodyCount == 0 || weight <= 0)
{
return;
}

double minX = double.MaxValue;
double minY = double.MaxValue;
double maxX = double.MinValue;
double maxY = double.MinValue;

for (int i = 0; i < bodyCount; i++)
{
// A pinned or frozen body is placed by whoever pinned it, and sliding the graph would move
// it. One is enough to say where the graph goes, so the whole pass stands down.
if (bodies[i].IsPinned != 0 || bodies[i].IsFrozen != 0)
{
return;
}

minX = Math.Min(minX, bodies[i].Position.X);
minY = Math.Min(minY, bodies[i].Position.Y);
maxX = Math.Max(maxX, bodies[i].Position.X + bodies[i].Dimensions.X);
maxY = Math.Max(maxY, bodies[i].Position.Y + bodies[i].Dimensions.Y);
}

Vec2D drawnCentre = new((minX + maxX) * 0.5, (minY + maxY) * 0.5);
Vec2D shift = (WorldOrigin - drawnCentre) * Math.Min(weight * RecentringRate, 1.0);

for (int i = 0; i < bodyCount; i++)
{
bodies[i].Position += shift;
}
}

private void IntegrateMotion(double dt)
{
double maxForce = Settings.MaxForce;
Expand Down
14 changes: 13 additions & 1 deletion ForceDirectedLayout/LayoutSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,19 @@ public struct LayoutSettings
/// </summary>
public double LinkUntwistStrength;

/// <summary>Strength of the gravity force pulling each body toward the gravity target.</summary>
/// <summary>
/// Spring constant of the pull toward the gravity target, per unit of distance from it.
/// </summary>
/// <remarks>
/// A spring rather than a constant pull, and the difference is what decides where a graph sits.
/// A pull of the same size wherever a body happens to be sums, over the whole graph, to a step
/// function of position: it depends on how many bodies lie each side of the target and not on how
/// far, so anywhere the counts balance it is exactly zero and nothing holds the graph in place at
/// all. A twelve-node chain measured that way settled 79 units to one side of the target and stayed
/// there, and pushed 600 units the other way it came to rest 79 units to the *other* side — the
/// same distance out, on whichever side it happened to arrive from, because both are edges of the
/// same dead band. Proportional to distance there is one resting place and it is the target.
/// </remarks>
public double GravityStrength;

/// <summary>Blend factor from centroid (0) to world origin (1) for the gravity target.</summary>
Expand Down
14 changes: 13 additions & 1 deletion ForceDirectedLayout/PhysicsSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,19 @@ public sealed record PhysicsSettings
/// </summary>
public double LinkUntwistStrength { get; init; } = 0.1;

/// <summary>Strength of the gravity force pulling each body toward the gravity target.</summary>
/// <summary>
/// Spring constant of the pull toward the gravity target, per unit of distance from it.
/// </summary>
/// <remarks>
/// A spring rather than a constant pull, and the difference is what decides where a graph sits.
/// A pull of the same size wherever a body happens to be sums, over the whole graph, to a step
/// function of position: it depends on how many bodies lie each side of the target and not on how
/// far, so anywhere the counts balance it is exactly zero and nothing holds the graph in place at
/// all. A twelve-node chain measured that way settled 79 units to one side of the target and stayed
/// there, and pushed 600 units the other way it came to rest 79 units to the *other* side — the
/// same distance out, on whichever side it happened to arrive from, because both are edges of the
/// same dead band. Proportional to distance there is one resting place and it is the target.
/// </remarks>
public double GravityStrength { get; init; } = 50.0;

/// <summary>Blend factor from centroid (0) to world origin (1) for the gravity target.</summary>
Expand Down
5 changes: 5 additions & 0 deletions tests/ForceDirectedLayout.Tests/ForceLayoutTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,11 @@ public void GenericFacade_Step_WithEdge_PullsNodesCloser()
RestLinkLength = 225.0,
OverlapMargin = 0,
DampingFactor = 0.1,

// No pull to the origin either, so a position in these tests means what the flattening force did
// to it. Left on, the recentring pass slides the pair bodily so its box straddles the origin,
// which moves both bodies equally and splays neither - true, and not what is being asked.
OriginAnchorWeight = 0,
};

[TestMethod]
Expand Down
Loading