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
11 changes: 8 additions & 3 deletions Sources/MermaidLayout/DiagramLayoutFlowchart.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,15 @@ extension DiagramLayoutEngine {
// Subgraphs route through the recursive cluster wrapper, which lays
// out each group's interior as its own sub-chart and places it as a
// sized box in the parent — so this core never has to know about them.
if chart.subgraphs.isEmpty {
return layoutFlat(chart, measure: measure, spacing: spacing)
let raw = chart.subgraphs.isEmpty
? layoutFlat(chart, measure: measure, spacing: spacing)
: layoutClustered(chart, measure: measure, spacing: spacing)
// Opt-in grid snap, applied at the single dispatch both render paths and
// the lint IR call — so all three see identical snapped geometry.
if let unit = spacing.gridSnap, unit > 0 {
return GridQuantizer.quantize(raw, unit: unit)
}
return layoutClustered(chart, measure: measure, spacing: spacing)
return raw
}

static func layoutFlat(_ chart: Flowchart, measure: DiagramTextMeasurer,
Expand Down
10 changes: 8 additions & 2 deletions Sources/MermaidLayout/DiagramSpacing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,19 @@ public struct DiagramSpacing: Hashable, Sendable {
public var layerGap: CGFloat?
/// Absolute canvas margin, overriding `scale`.
public var margin: CGFloat?
/// When set (points), snaps the laid-out geometry of box-family diagrams onto
/// a grid of this pitch (see ``GridQuantizer``). Opt-in and off by default;
/// 4 is the natural unit. Ignored by families a grid doesn't apply to.
public var gridSnap: CGFloat?

public init(scale: CGFloat = 1, nodeGap: CGFloat? = nil,
layerGap: CGFloat? = nil, margin: CGFloat? = nil) {
layerGap: CGFloat? = nil, margin: CGFloat? = nil,
gridSnap: CGFloat? = nil) {
self.scale = max(scale, 0.4) // below this, labels physically collide
self.nodeGap = nodeGap
self.layerGap = layerGap
self.margin = margin
self.gridSnap = gridSnap.map { max($0, 0) }
}

/// The tuned defaults every fixture and benchmark runs at.
Expand All @@ -46,7 +52,7 @@ public struct DiagramSpacing: Hashable, Sendable {
/// A stable digest for render-cache keys.
public var fingerprint: String {
func f(_ v: CGFloat?) -> String { v.map { String(format: "%.1f", $0) } ?? "-" }
return "s\(String(format: "%.2f", scale))|\(f(nodeGap))|\(f(layerGap))|\(f(margin))"
return "s\(String(format: "%.2f", scale))|\(f(nodeGap))|\(f(layerGap))|\(f(margin))|g\(f(gridSnap))"
}

// Engines resolve their tuned base values through these.
Expand Down
102 changes: 102 additions & 0 deletions Sources/MermaidLayout/GridQuantizer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import Foundation
#if canImport(CoreGraphics)
import CoreGraphics
#endif

/// Snaps a laid-out box diagram onto a pixel grid — the transform the
/// grid-alignment metric (``DiagramLayoutLinter/gridAlignment(_:unit:)``) was built
/// to guard. Opt-in via ``DiagramSpacing/gridSnap`` and applied inside the layout
/// dispatch, so both render paths (CoreGraphics + `RenderScene`) and the lint IR
/// inherit *identical* snapped geometry — the draw-vs-scene conformance ratchet
/// stays green for free.
///
/// Node/container frames snap so every coordinate lands on the grid: the origin
/// rounds **down** and the far corner rounds **up**, so a box only ever grows
/// (never clipping its measured label) and its width/height are grid multiples.
/// Edge endpoints are then **re-anchored** onto the snapped node borders — a grown
/// box moves its right/bottom edge independently of the endpoint, so snapping the
/// point alone would detach the arrow — and interior waypoints snap to the nearest
/// grid line, which preserves the orthogonal routing (a segment's shared
/// coordinate snaps identically at both ends).
///
/// Flowchart today; the other box families (block, C4, architecture, ER, class,
/// state) extend the same pattern.
enum GridQuantizer {

static func quantize(_ layout: FlowchartLayout, unit u: CGFloat) -> FlowchartLayout {
guard u > 0 else { return layout }

func down(_ v: CGFloat) -> CGFloat { (v / u).rounded(.down) * u }
func up(_ v: CGFloat) -> CGFloat { (v / u).rounded(.up) * u }
func near(_ v: CGFloat) -> CGFloat { (v / u).rounded() * u }
func snapPoint(_ p: CGPoint) -> CGPoint { CGPoint(x: near(p.x), y: near(p.y)) }

// A box grows to the enclosing grid cell: origin down, far corner up — so
// width/height are grid multiples and the content never gets clipped.
func snapRect(_ r: CGRect) -> CGRect {
let x0 = down(r.minX), y0 = down(r.minY)
let x1 = Swift.max(up(r.maxX), x0 + u), y1 = Swift.max(up(r.maxY), y0 + u)
return CGRect(x: x0, y: y0, width: x1 - x0, height: y1 - y0)
}

let nodes = layout.nodes.map {
FlowchartLayout.PlacedNode(id: $0.id, label: $0.label, shape: $0.shape, frame: snapRect($0.frame))
}
let containers = layout.containers.map {
FlowchartLayout.Container(id: $0.id, label: $0.label, frame: snapRect($0.frame), depth: $0.depth)
}

// Re-anchor an edge endpoint onto the snapped border of the node it
// attached to (the nearest original frame). A point not near any node is
// free routing geometry and just snaps to the grid.
let orig = layout.nodes.map(\.frame)
let snapped = nodes.map(\.frame)
func reanchor(_ p: CGPoint) -> CGPoint {
var best = -1
var bestDist = CGFloat.greatestFiniteMagnitude
for (i, f) in orig.enumerated() {
let d = distanceToRect(p, f)
if d < bestDist { bestDist = d; best = i }
}
guard best >= 0, bestDist <= 8 else { return snapPoint(p) }
return projectToPerimeter(p, snapped[best])
}

let edges = layout.edges.map { e -> FlowchartLayout.PlacedEdge in
let start = reanchor(e.start), end = reanchor(e.end)
var pts = e.points.map(snapPoint)
if !pts.isEmpty { pts[0] = start; pts[pts.count - 1] = end }
return FlowchartLayout.PlacedEdge(
start: start, end: end, points: pts, label: e.label, dashed: e.dashed,
hasArrow: e.hasArrow, backArrow: e.backArrow, labelPoint: e.labelPoint.map(snapPoint))
}

// The canvas grows to hold any box that snapped past the old bounds.
let maxX = (nodes.map(\.frame.maxX) + containers.map(\.frame.maxX)).max() ?? layout.size.width
let maxY = (nodes.map(\.frame.maxY) + containers.map(\.frame.maxY)).max() ?? layout.size.height
let size = CGSize(width: up(Swift.max(layout.size.width, maxX)),
height: up(Swift.max(layout.size.height, maxY)))
return FlowchartLayout(size: size, nodes: nodes, edges: edges, containers: containers)
}

/// Euclidean distance from `p` to the nearest point of `r` (0 when inside).
private static func distanceToRect(_ p: CGPoint, _ r: CGRect) -> CGFloat {
let cx = Swift.min(Swift.max(p.x, r.minX), r.maxX)
let cy = Swift.min(Swift.max(p.y, r.minY), r.maxY)
return hypot(p.x - cx, p.y - cy)
}

/// The point on `r`'s perimeter nearest to `p` (clamp into the rect, then push
/// to the closest of the four edges).
private static func projectToPerimeter(_ p: CGPoint, _ r: CGRect) -> CGPoint {
let cx = Swift.min(Swift.max(p.x, r.minX), r.maxX)
let cy = Swift.min(Swift.max(p.y, r.minY), r.maxY)
let dLeft = cx - r.minX, dRight = r.maxX - cx
let dTop = cy - r.minY, dBottom = r.maxY - cy
let m = Swift.min(Swift.min(dLeft, dRight), Swift.min(dTop, dBottom))
if m == dLeft { return CGPoint(x: r.minX, y: cy) }
if m == dRight { return CGPoint(x: r.maxX, y: cy) }
if m == dTop { return CGPoint(x: cx, y: r.minY) }
return CGPoint(x: cx, y: r.maxY)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,10 @@ reports the fraction of coordinates on the grid, and `report(_:)` appends a
`grid:` line for them. It is deliberately **not** a `LayoutViolation`: today's
layouts sit well below full alignment, so a warning would fire on every diagram.
Instead a test tracks a per-type floor the layout can only ratchet up — the
baseline for a future grid-snapping layout pass.
baseline the grid-snapping pass is verified against. That pass, ``GridQuantizer``,
is opt-in via ``DiagramSpacing/gridSnap`` (flowchart today): it snaps node boxes
onto the grid and re-anchors edges, and because it runs inside the shared layout
dispatch, both render paths and this lint IR see identical snapped geometry.

```swift
if let ga = DiagramLayoutLinter.gridAlignment(scene) {
Expand Down
96 changes: 96 additions & 0 deletions Tests/MermaidLayoutTests/GridQuantizerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import XCTest
#if canImport(CoreGraphics)
import CoreGraphics
#else
import Foundation
#endif
@testable import MermaidLayout

/// The grid-snap transform (`GridQuantizer`, opt-in via `DiagramSpacing.gridSnap`).
/// The metric side is guarded by `GridAlignmentTests`; here we prove the transform
/// (a) fully aligns the geometry it snaps, (b) keeps the layout lint-clean — edges
/// stay attached to their (grown) boxes and no box is occluded or overlapping —
/// and (c) does nothing at all when off.
final class GridQuantizerTests: XCTestCase {

private let measure: DiagramTextMeasurer = { text, size in
CGSize(width: CGFloat(max(text.count, 1)) * size * 0.6, height: size + 4)
}

private var fixturesDir: URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
.appendingPathComponent("Fixtures/diagrams")
}

private func flowchart() throws -> Flowchart {
let source = try String(contentsOf: fixturesDir.appendingPathComponent("flowchart.mmd"), encoding: .utf8)
guard case let .flowchart(chart)? = MermaidParser.parse(source) else {
throw XCTSkip("flowchart fixture did not parse to a flowchart")
}
return chart
}

/// Snapping the flowchart fixture drives every node coordinate onto the 4px grid.
func testSnapAlignsAllNodeCoords() throws {
let chart = try flowchart()
let snapped = DiagramLayoutEngine.layout(chart, measure: measure, spacing: DiagramSpacing(gridSnap: 4))
for node in snapped.nodes {
for (name, v) in [("x", node.frame.minX), ("y", node.frame.minY),
("w", node.frame.width), ("h", node.frame.height)] {
let onGrid = abs(v - (v / 4).rounded() * 4) < 1e-6
XCTAssertTrue(onGrid, "node \(node.id) \(name)=\(v) is off the 4px grid")
}
}
// …and the grid-alignment metric agrees end-to-end (through the lint IR).
let scene = DiagramScene.from(snapped, measure: measure)
let ga = DiagramLayoutLinter.gridAlignment(scene)
XCTAssertEqual(ga?.coordFraction ?? 0, 1.0, accuracy: 1e-9,
"expected full grid alignment after snapping")
}

/// Snapping must not introduce layout errors: grown boxes keep their edges
/// attached (no `edge-endpoint-detached`), out of their interiors (no
/// `edge-occludes-node`), and apart (no `nodes-overlap`).
func testSnappedLayoutStaysLintClean() throws {
let chart = try flowchart()
let snapped = DiagramLayoutEngine.layout(chart, measure: measure, spacing: DiagramSpacing(gridSnap: 4))
let scene = DiagramScene.from(snapped, measure: measure)
let errors = DiagramLayoutLinter.lint(scene).filter { $0.severity == .error }
XCTAssertTrue(errors.isEmpty,
"grid-snapped flowchart introduced layout errors:\n" +
errors.map { " ✗ [\($0.kind)] \($0.detail)" }.joined(separator: "\n"))
}

/// Boxes only ever grow (never clip): each snapped frame contains its original.
func testSnappedBoxesNeverShrink() throws {
let chart = try flowchart()
let raw = DiagramLayoutEngine.layout(chart, measure: measure)
let snapped = DiagramLayoutEngine.layout(chart, measure: measure, spacing: DiagramSpacing(gridSnap: 4))
let rawByID = Dictionary(uniqueKeysWithValues: raw.nodes.map { ($0.id, $0.frame) })
for node in snapped.nodes {
guard let o = rawByID[node.id] else { continue }
XCTAssertLessThanOrEqual(node.frame.minX, o.minX + 1e-6, "\(node.id) x grew inward")
XCTAssertLessThanOrEqual(node.frame.minY, o.minY + 1e-6, "\(node.id) y grew inward")
XCTAssertGreaterThanOrEqual(node.frame.maxX, o.maxX - 1e-6, "\(node.id) clipped on the right")
XCTAssertGreaterThanOrEqual(node.frame.maxY, o.maxY - 1e-6, "\(node.id) clipped on the bottom")
}
}

/// Off by default: an unset `gridSnap` leaves geometry byte-for-byte identical.
func testGridSnapOffIsIdentity() throws {
let chart = try flowchart()
let a = DiagramLayoutEngine.layout(chart, measure: measure) // default
let b = DiagramLayoutEngine.layout(chart, measure: measure, spacing: .regular) // explicit, gridSnap nil
XCTAssertNil(DiagramSpacing().gridSnap)
XCTAssertEqual(a.nodes.count, b.nodes.count)
for (x, y) in zip(a.nodes, b.nodes) { XCTAssertEqual(x.frame, y.frame) }
}

/// The cache fingerprint distinguishes snapped from unsnapped (else a snapped
/// and unsnapped render of the same source/theme would collide in the cache).
func testFingerprintReflectsGridSnap() {
XCTAssertNotEqual(DiagramSpacing().fingerprint, DiagramSpacing(gridSnap: 4).fingerprint)
XCTAssertNotEqual(DiagramSpacing(gridSnap: 4).fingerprint, DiagramSpacing(gridSnap: 8).fingerprint)
}
}
Loading