Skip to content

shape: cycle - trace arcs to shape borders (close #1578) - #2742

Open
yasumorishima wants to merge 21 commits into
d2lang:masterfrom
yasumorishima:feat/cycle-border-tracing
Open

yasumorishima wants to merge 21 commits into
d2lang:masterfrom
yasumorishima:feat/cycle-border-tracing

Conversation

@yasumorishima

Copy link
Copy Markdown

Summary

Closes #1578.

This is a continuation of #2362 by @alixander. That PR introduced the shape: cycle layout but the curves did not start or stop on shape borders, which the bounty on #1578 asks to fix. This branch is based on alixander:cycle (the head of #2362) and adds the missing border-tracing on top, plus tests and a couple of small geometry fixes.

If preferred, I am happy to either land this as the supersede PR or rebase the four new commits onto a fresh branch off master. Glad to follow whichever workflow suits the maintainers best.

Problem

In #2362 the cycle edges were sampled as a 30-point arc between source and destination shape centers, then handed to Edge.TraceToShape to clip the route to the shape borders. With small shapes on a layout circle, every arc sample near the source center fell inside the source rectangle, so the segment-edge clipper found no crossing and left the curve starting at the shape center instead of on the border.

Fix

Solve the layout circle against each shape analytically and limit the arc to the angles where it crosses the source and destination borders, then refine the endpoints onto the actual shape outline:

  • New Segment.IntersectCircle(center, radius) helper in lib/geo solves a quadratic for segment-circle crossings (single point on a tangent contact).
  • d2cycle.createCircularArc now intersects the layout circle with the four edges of each shape's bounding box, picks the angles where the arc enters / exits, and samples between them.
  • The endpoints are then passed through shape.TraceToShapeBorder so non-rectangular shapes (circle, oval, hexagon, cloud, ...) end on their actual outline rather than on the bounding box border. For rectangles this is a no-op.
  • When the analytic step degenerates (zero sweep, no crossing in arc range), fallbackStraightRoute produces a straight connection whose endpoints are also clipped to the shape border via the same helper, instead of returning the shape centers.

The renderer side (cubic-bezier emission for IsCurve) is left exactly as in #2362, so the visual style of the curves matches that PR.

Tests

  • Regenerated e2etests/testdata/txtar/cycle-diagram/{dagre,elk}/{board.exp.json,sketch.exp.svg} with TA=1. The new SVGs render every cycle connection starting and ending on a shape border.
  • New TestSegmentIntersectCircle in lib/geo/segment_test.go covers eight cases (chord, tangent, off-origin circle, endpoint on circle, both endpoints inside / outside, vertical chord, degenerate zero-length segment).
  • go test ./d2layouts/... ./lib/geo/... all green.
  • Full go test ./e2etests/... was run; only failures are the existing Go 1.26 url.URL JSON field-order shifts that already affect master (e.g. icon-label, cycle-order, sql-table-reserved) and are unrelated to this change.

Self-review

Both findings from CodeRabbit on the equivalent fork PR were addressed:

  • Endpoints are now clipped to the actual shape outline (not just the bounding box) via shape.TraceToShapeBorder.
  • IntersectCircle no longer reports a tangent contact twice (disc > 0 guard added with regression test).
  • The fallback paths now produce shape-border endpoints instead of returning the shape centers.

Bounty

/claim #1578

Alexander Wang and others added 9 commits February 19, 2025 08:04
…ction

Closes d2lang#1578.

The previous cycle layout sampled an arc between shape centers and relied
on TraceToShape to clip the route. With small shapes sitting on a large
radius, every arc sample near the source center fell inside the rectangle,
so the segment-based clipper never found an edge crossing and left the
curve starting at the shape center.

Now the layout solves the layout circle against each rectangle's four
edges analytically and limits the arc to the angles where it crosses the
source and destination borders, so every connection starts and ends
exactly on a shape boundary.

Also adds Segment.IntersectCircle to lib/geo and removes the unused
clamp helpers from d2cycle/layout.go.
Addresses CodeRabbit review feedback on #1.

Major: my analytic intersection only used `geo.Box` edges, which is the
shape's bounding box. For non-rectangular shapes (circle, oval, hexagon,
cloud, ...) the bounding box border is not the actual shape outline, so
connections were landing on the bounding box rather than on the shape.
Fixed by passing each arc endpoint through `shape.TraceToShapeBorder`,
which walks the line from the shape center to the box-border point and
returns the intersection with the shape's perimeter (a no-op for
rectangular shapes).

Minor: `Segment.IntersectCircle` appended both quadratic roots even when
the discriminant was zero (a tangent contact), so a graze was reported
as two identical points. Fixed by emitting the second root only when
the discriminant is strictly positive, and added a tangent regression
test case.
Addresses CodeRabbit second-review feedback on #1.

When `nextBoundaryAngle` could not find an arc-range crossing (very
narrow sweep, degenerate geometry, ...) the previous fallbacks let
`startAngle = srcAngle` / `endAngle = dstAngle` and emitted a route
whose first/last point coincided with the shape center, since the
center lies on the layout circle. That regressed the very fix the PR
introduces and also fed `TraceToShapeBorder` a center-as-rectBorder
input.

Now the createCircularArc flow falls through to the straight-line
fallback whenever either boundary angle is missing or the resulting
range is empty, and `fallbackStraightRoute` itself runs each endpoint
through a new `clipToShapeBorder` helper. The helper extends a ray
from the shape center toward the other endpoint, intersects the
bounding box, and refines via `TraceToShapeBorder` for non-rectangular
shapes. Both fallbacks therefore emit shape-border endpoints, matching
the arc-success path.

The cycle-diagram fixture is unchanged because rectangle shapes never
hit the fallback in the existing test cases.
Point.Equals compares float64 exactly, so the chord case asserted
y == 4 against a computed 3.9999999999999982 and failed.
@yasumorishima
yasumorishima force-pushed the feat/cycle-border-tracing branch from 172fada to 2030067 Compare July 29, 2026 04:22
Adopt the github.com/d2lang/d2 module path (org migration) in the cycle layout files and regenerate the cycle-diagram sketch fixtures for the upstream SVG rendering changes.
The only conflict was in pathData(): this branch carried the WIP
rewrite of that function from the original cycle draft, while master
has since reformatted the same code with svg.FormatFloat. The WIP
version is not needed for border tracing (the arc route has
ARC_STEPS+1 = 31 points, so master final-curve handling applies, and
the straight fallback route sets IsCurve=false), so take master
version verbatim and drop the churn.

Regenerate the cycle-diagram sketch fixtures: the arc geometry is
unchanged (same M/C coordinates), the diff is svg.FormatFloat
trimming trailing zeros plus the unused-CSS pruning master added.
master added a raster/scene path that draws each shape from an
explicit type switch, with a default branch that asks lib/shape for
path commands. Square-family shapes have no path commands, so the
cycle container fell through and failed with "unsupported typed
geometry for cycle" in TestTargetShapePixels.

Handle cycle next to sequence_diagram and hierarchy in the four
square-family switches, add it to the shape lists the two scene
builder tests enumerate, and regenerate the target-shapes golden.
The scene builder commit made the cycle container visible for the first
time, which showed that the container box does not sit on the board it
wraps: d2layouts fits the container to the nested board and then places
it at (0, 0), and PositionNested offsets the contents by the container
position alone, so a nested board is expected to start at the origin.
positionObjects centers the ring on (0, 0) instead, leaving the upper
left half of the board negative, so the container was drawn offset by
half the ring.

Normalise the board at the end of the layout, including the arcs in the
bounds so the container encloses them, and record the size on the root
for FitToGraph. The shift is a translation applied to the shapes and the
routes together, so the arc endpoints stay on the shape borders: the
first arc keeps x = 28.476319 + 227 = 255.476319 exactly.

Handle cycle in the SVG rectangle case as well, next to sequence_diagram
and hierarchy, so SVG and raster agree on the container.
@yasumorishima

Copy link
Copy Markdown
Author

Merged master in and resolved the one conflict, which was in pathData(). This branch was still carrying the original draft's rewrite of that function, while master has since reformatted the same code with svg.FormatFloat, so I took master's version verbatim. None of the draft's extra branches are needed for border tracing: createCircularArc is the only place that sets IsCurve, and it always emits ARC_STEPS+1 = 31 points, so master's final-curve branch stays in range, while the degenerate path goes through fallbackStraightRoute, which sets IsCurve = false. The regenerated sketch.exp.svg keeps every arc coordinate (M 28.476319 -197.929132 …); the only diff is trailing zeros and master's unused-CSS pruning, and board.exp.json is unchanged.

Two follow-ups came out of the merge. They change what a cycle container looks like, so I want to flag them rather than bury them in the diff.

1. The cycle shape was not drawn by either renderer. TestTargetShapePixels (new on master) requires every entry of d2target.Shapes to render, and cycle failed it with unsupported typed geometry for cycle: square-family shapes have no path commands, so the scene builder's default branch cannot draw them. I handle cycle next to sequence_diagram and hierarchy in the four square-family switches, and added it to the two shape lists those tests enumerate. The SVG renderer had the same gap silently — cycle fell to default:, where baseShape.GetSVGPathData() returns nil, so containers were never painted — so cycle is in that rectangle case now too, and SVG and raster agree.

2. Once the container was visible, it was not sitting on the board it wraps. d2layouts fits the container to the nested board and then places it at (0, 0), and PositionNested offsets the contents by the container position alone, so a nested board is expected to start at the origin. positionObjects centers the ring on (0, 0) instead, which leaves the upper-left half of the board in negative coordinates, and the container was drawn offset by half the ring. normalizeToOrigin at the end of the layout translates the shapes and the arcs together — the arcs are included in the bounds so the container encloses them — and records the size on the root for FitToGraph. Being a pure translation it does not disturb what this PR is about: the first arc keeps x = 28.476319 + 227 = 255.476319 exactly, and every shape is inside its container in the regenerated board.

Before (containers absent) and after:

before

after

Also added the changelog entry, which I had missed.

go build ./..., go vet ./... and go test -count=1 ./... are clean on go1.27 (57 packages, e2e included).

If you would rather cycle containers stayed invisible, I am happy to take the rendering back out and find another way to satisfy the pixel-coverage test — I just did not want to leave SVG and PNG disagreeing with each other.

@yasumorishima
yasumorishima force-pushed the feat/cycle-border-tracing branch from 550fe38 to f1828a0 Compare September 8, 2026 11:44
…-tracing

# Conflicts:
#	ci/release/changelogs/next.md
v0.9.0 vendored TALA into the repository, so the txtar suite now runs
every case under dagre, elk and tala. The cycle-diagram case had no
tala goldens and failed with a missing board.

The layout is unchanged; these are the recorded output of the engine
that ships with the tree.
The exporter truncates positions and sizes to integers, and a circle
leaves shapes on half pixels, so the container could be written one
pixel narrower than the child sitting on its right edge and its border
was drawn through that child.

It showed up only under tala, where the board lands on a fractional
offset: container 1 ended at 699 while b ended at 700. Sweeping all
162 txtar goldens, that was the only case in the suite of a child
outside its container that is not a sequence diagram lifeline.

Rounding the board size up makes the box contain its children under
all three engines, and the three now agree.
@yasumorishima

Copy link
Copy Markdown
Author

Rebased onto master after v0.9.0, and two things in the push are worth a note because they move goldens.

TALA now ships in the tree, so the txtar suite runs every case under dagre, elk and tala. This case had no tala goldens and failed with a missing board; 51 of the 54 txtar cases carry them, so it now does too.

The cycle board is rounded up to whole pixels (d2layouts/d2cycle/layout.go). The exporter truncates positions and sizes to integers, and a circle leaves shapes on half pixels, so the container could be written one pixel narrower than the child sitting on its right edge, and its border was drawn through that child:

container 1  int(246.815) + int(453.5) = 699
child b      int(647.315) + 53         = 700

It only showed under tala, where the board lands on a fractional offset — dagre and elk happened to land flush. Sweeping all 162 txtar goldens for a child outside its container, this was the only case in the suite that is not a sequence diagram lifeline. Rounding the board size up makes the box contain its children under every engine, which is why all three goldens moved: the three now agree on the same relative geometry, where before they differed by a pixel each way.

go build, go vet and go test ./... are clean (78 packages, no failures).

yasumorishima and others added 3 commits September 14, 2026 07:30
A self edge shares one shape between its source and its destination, so
the arc geometry degenerates: the two centres coincide, the sweep is
zero, and the fallback clips both endpoints to the same point. The route
was left as two identical points, which the exporter wrote out as

  d="M NaN NaN L NaN NaN"

and which, now that the render target is validated, fails the whole
render with "route[0:1] must have a finite, non-zero segment length".

Give the self edge a loop of its own instead: a circle tangent to the
shape border on the far side from the centre of the ring, sampled the
same way the arcs are. Shapes that sit on the centre have no outward
direction, so their loop goes straight up.

The txtar case is a new entry rather than a fourth board on
cycle-diagram, because adding a board there moves the other three and
rewrites goldens that have nothing to do with this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174tmEsVYhznCbNSYEZWett
calculateRadius spaces the shapes around the ring by sin(pi/n), and
sin(pi/1) is 1.2e-16 rather than 0, so a cycle holding a single shape
came out with a radius of about 5.7e17.

At that magnitude the gap between representable doubles is wider than
the shapes themselves, so anything computed on the ring collapses. It
was visible before this branch as a board written 4.3e17 pixels tall;
fitting the container to its contents hid that, but the arithmetic was
still wrong underneath, and the self loop of a lone shape came out
flattened onto y = 0 while still passing validation.

One shape needs no room for neighbours, so return the minimum radius.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174tmEsVYhznCbNSYEZWett

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

shape: cycle

1 participant