feat(library): customizable straight edges — waypoints, tidy-tree layout & obstacle routing#830
feat(library): customizable straight edges — waypoints, tidy-tree layout & obstacle routing#830FelixTJDietrich wants to merge 8 commits into
Conversation
…out & obstacle routing Brings the "straight-hook" edge regime (UML use-case, syntax-tree, petri-net) to parity with the orthogonal step-edge engine, closing #392 and addressing #282/#128. Track A — manual waypoints: straight edges gain draggable free-2D waypoints. A ghost midpoint per segment materialises a bend past a drag threshold; a bend drags in 2D and is removed by double-click or drag-to-collinear (continuous redundancy pruning). Model: data.points holds INTERIOR waypoints only (JointJS "vertices" model); route is [source, ...points, target]. New utils/geometry/freeWaypoints.ts (pure, integer). The solver's two manual-points call sites become a regime-aware diagonal passthrough (no orthogonal reprojection). Marker/grip orientation now follows the terminal segment. v3→v4 migration clears the (previously inert) legacy path on straight-hook edges. Track B — syntax-tree tidy layout (#282): a user-triggered hierarchical layout derives the tree from SyntaxTreeLink edges and repositions nodes (hand-written integer Buchheim/Walker/Reingold–Tilford, variable-width sibling separation) so parent→child links no longer overlap siblings. Malformed input (forests, cycles, multi-parent) degrades gracefully. One Yjs transaction / one undo step. Exposed as ApollonEditor.layoutSyntaxTree() and a SyntaxTree-only builtin control. Track D — obstacle-avoiding straight router: straight edges auto-bend around any node in their path (a clear chord stays straight) via a deterministic reduced visibility graph + integer A* (utils/geometry/straightPolylineRouter.ts, integerGeometry.ts — isqrt/turn buckets, no hypot/atan2 on any decision path). User waypoints are honoured as mandatory checkpoints. Memoryless, preview==commit, byte-identical across peers. Bundle budgets raised deliberately (main 118→126 kB, worker 40→44 kB) with headroom. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4AtGeJaSWiGooNonpVcxN
Claudia-Anthropica
left a comment
There was a problem hiding this comment.
@FelixTJDietrich The core routing approach and pure-helper coverage are substantial, but the new waypoint schema is not safe for already-saved 4.x models, and several obstacle/gesture/label integrations break once routes contain both authored and automatic bends. See the inline comments for the concrete cases that need fixing.
| // through INTERIOR waypoints only. The legacy v3 `path` is the full rendered | ||
| // polyline including endpoints and was inert for these types, so importing it as | ||
| // waypoints would sprout spurious (double-endpoint) bends. Clear it. | ||
| if (STRAIGHT_HOOK_EDGE_TYPES.has(edgeType as string)) { |
There was a problem hiding this comment.
@FelixTJDietrich [high] This only fixes direct v3 imports. Models already converted and saved by current releases are 4.x and can still carry the old full-route data.points; this branch still exports 4.1.0, so those points are indistinguishable from new interior waypoints and will render as bogus bends (and can then be resaved as user data). Version this schema change and normalize older 4.x models too.
🤖 Prompt for AI agents
In library/lib/utils/versionConverter.ts, legacy full-route points on straight-hook edges are cleared only during v3 conversion, while pre-feature 4.x models pass through unchanged. Add a versioned normalization for pre-feature 4.x models and bump the exported model version so new interior waypoints remain distinguishable and preserved.
There was a problem hiding this comment.
@FelixTJDietrich This is still open: the current import path still accepts every 4.x model unchanged, and the exported schema remains 4.1.0, so legacy full-route points are still interpreted as new interior waypoints.
| const line = routeStraightPolyline({ | ||
| source: endpoints.adjustedSource, | ||
| target: endpoints.adjustedTarget, | ||
| checkpoints: getStraightHookInterior(edge), |
There was a problem hiding this comment.
@FelixTJDietrich [high] candidateBounds only spans the source and target nodes, but these checkpoints can be dragged arbitrarily far outside that box. getEdgeObstacles then omits nodes around the distant waypoint legs, so the straight router sees no blocker and routes directly through them. Expand the obstacle-query bounds to include every checkpoint and add an integration test with an out-of-bounds waypoint.
🤖 Prompt for AI agents
In library/lib/utils/geometry/edgeGeometrySolver.ts, straight-hook obstacle discovery uses endpoint-only candidate bounds even when manual checkpoints lie outside them. Expand the bounds passed to getEdgeObstacles to cover the source, target, and all straight-hook checkpoints, and add a regression test proving a distant checkpoint leg avoids a nearby node.
There was a problem hiding this comment.
@FelixTJDietrich This is still open: candidateBounds is still computed solely from the endpoint nodes and passed unchanged to getEdgeObstacles, so nodes near a distant manual checkpoint remain outside the query.
| const isPolyline = renderPoints.length > 2 || lineJumps.length > 0 | ||
| const currentPath = useMemo(() => { | ||
| if (lineJumps.length > 0) return buildEdgePath(renderPoints, lineJumps) | ||
| if (isPolyline) return buildEdgePath(renderPoints, lineJumps) |
There was a problem hiding this comment.
@FelixTJDietrich [medium] buildEdgePath draws a continuous polyline, so bending an include/extend edge removes the midpoint gap that calculateStraightPath creates for the stereotype text. The straight-edge label components also still position from the endpoint chord, which makes use-case and Petri labels float away from a bent route (often onto the node that caused the detour). Derive the label anchor/orientation from the local arc-mid segment and preserve the include/extend gap on that segment.
🤖 Prompt for AI agents
In library/lib/hooks/useStraightPathEdge.ts, bent straight-hook paths bypass the include/extend text gap and expose only endpoint-chord geometry to label renderers. Build a polyline path that carves the stereotype gap on the chosen local segment, pass that segment's endpoints to straight-edge labels, and add rendering-geometry tests for bent use-case and Petri edges.
There was a problem hiding this comment.
@FelixTJDietrich This is still open: bent routes still use buildEdgePath without the include/extend gap, and label geometry still exposes only the route endpoints plus the generic midpoint instead of the local label segment.
…ment Straight-hook edges attached at whatever handle they were drawn on, so lines left from arbitrary corners and multiple connections to one node stacked on a single point — visibly worse than the orthogonal engine's coordinated attachment. Unbent, unpinned straight-hook edges are now free ends that flow through the SAME side + port machinery as step edges: assignSides (facing side toward the partner), assignPorts (balanced centred band + angular order for a shared node side) and selectEdgeAnchors — differing only in the final routing primitive, which stays a straight, obstacle-avoiding polyline. Authoring a bend pins those facing-side sites (like a step-edge bend commit) so the endpoints do not snap to the drawn handle, and the hook now renders the solver's chosen attachment rather than the stored handle. - collectFixedPorts: a straight-hook edge is no longer pinned to its drawn handle; only an authored bend or explicit pin fixes an endpoint. - reservation pass: only a bent straight-hook edge reserves a fixed diagonal route; an unbent one is band-assigned like a step edge. - main pass: unbent straight-hook edges run selectEdgeAnchors (facing side + band), then route straight; bent ones route straight through their waypoints. Tests: facing-side centred attachment and balanced spread across three children, plus the existing passthrough/obstacle/determinism cases. Full suite 1785 green; lint, build and size all pass (worker 39.99/44 kB, main 122.15/126 kB). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4AtGeJaSWiGooNonpVcxN
Claudia-Anthropica
left a comment
There was a problem hiding this comment.
@FelixTJDietrich The facing-side and balanced-port work is a useful addition, but it does not address the five existing routing, migration, gesture, and label findings. Those threads remain open, so I’m requesting changes again on this SHA.
…acle jogs Automatic obstacle avoidance produced backtracking, spaghetti routes on dense syntax trees (e.g. an edge jogging ABOVE its own source node, or detouring far right and back). Two fixes, matching how tree drawing actually works: - Syntax-tree links are drawn as straight parent→child lines; node overlaps are resolved by the tidy-tree layout (Track B), NOT by per-edge routing — the approach d3/dagre/ELK take for trees. Side selection stays obstacle-aware, so a stacked node still makes the edge pick a side that clears it with a straight line rather than one that runs through it. Use-case / petri edges (general graphs) keep automatic obstacle avoidance. - The straight polyline router never treats an edge's OWN source/target nodes as obstacles (they are where it attaches), removing the backwards-jog artifact. Verified on the reported diagrams: the dense tree now has 0 auto-bends with sensible facing-side attachment, and the 3-node stacked case is a straight line that clears the middle node. Full suite 1786 green; lint, build, size all pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4AtGeJaSWiGooNonpVcxN
Claudia-Anthropica
left a comment
There was a problem hiding this comment.
@FelixTJDietrich The latest routing adjustments do not address the five existing migration, obstacle-query, waypoint-gesture, and label-geometry requests. Those threads remain open, so I’m requesting changes again on this SHA.
…e routing Automatic detours were shortest-path-shaped rather than diagram-shaped: edges slid out sideways along a node's own edge (90-degree "exit angles"), rounded corners in staircases of tiny kinks, took long detours around groups of nodes, and ignored other edges entirely. The router now optimises the things that decide whether a route looks deliberate. The search runs over DIRECTED EDGES instead of vertices, because the cost of arriving somewhere depends on the direction you arrived from. That makes these exact and additive, all in pixels on the shared ROUTING_COST scale: - endpoint angle: leaving/meeting a node at anything but a right angle to its side is priced above a bend, so the search spends a corner (or attaches on another side) rather than grazing along the node — the reported artefact; - bend: a flat per-corner charge (the engine's `bendInGridCells`) PLUS a sharpness-graduated turn term. The floor is what stops a staircase of near-free micro-bends from beating one honest corner; - neighbours: crossing / collinear overlap / parallel crowding, delegated to the shared `polylineConflictCost` so both regimes price a crossing identically. Siblings sharing a node are exempt (the same carve-out the orthogonal neighbour scan makes) — they are meant to fan out together. Two further fixes: obstacle corners are rung at both the guaranteed margin and the roomier preferred one, with roomy corners dropped where they fall inside another node — a single wide ring welds neighbouring nodes together and closes the gap between them, which is what caused the long detours. And a string-pulling pass removes any bend the route does not need, accepted only when it strictly improves the same objective. Straight-edge bend handles now reuse the step edge's `edge-bend-handle` pill, styling and hover reveal, so the two regimes present one control set (a straight edge's handle carries a `move` cursor, since it has two free axes, not one). Measured on the reported diagrams: node crossings 0 everywhere; the dense parse tree drops from 11 bends / 90-degree worst exit to 6 bends / 63 degrees with total length down from 2708 to 2257. Full suite 1787 green; lint, build, size and knip all pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4AtGeJaSWiGooNonpVcxN
Claudia-Anthropica
left a comment
There was a problem hiding this comment.
@FelixTJDietrich The latest routing-cost changes do not address the five existing migration, obstacle-query, waypoint-gesture, and label-geometry requests. Those threads remain open, so I’m requesting changes again on this SHA.
A mirror-symmetric diagram was not drawn symmetrically, and a node's outgoing edges did not fan out: two connectors could leave from the SAME pixel, and a mirrored pair could attach to different sides entirely. Three causes, each a dimension of the step-edge cost model that straight edges were not really getting: - The coordinated band seat was passed to `selectEdgeAnchors` as a soft preference. A straight edge can always shorten itself by aiming its port at the partner, so the preference lost every time and whole fans collapsed onto one or two points. On a shared side the seat is now AUTHORITATIVE (a pin), which is what the original port-assignment design specified — the fan is evenly spread again. - `assignSides` minimises ORTHOGONAL corners, which is the wrong objective for a line that pays no corner to reach any side; what it pays for is its departure angle. Straight-edge sides now come from the direction of the partner (`normalAlignedSide`, added to the shared `rectSides` vocabulary) — the "partner direction decides side" rule this pipeline already cites. Being a pure function of the two rectangles, it is mirror-symmetric by construction, which corner-minimisation is not. - A LONE end (a leaf) has no band seat, so its side was chosen freely and a mirrored pair could disagree — one leaf attached on its left, its twin on top. Lone ends now take the CENTRE of their aligned side, the same "centre it when nothing competes" rule `endpointPlacementCost` rewards for step edges. Overlap is also now priced between siblings. Connectors sharing a node are exempt from CROWDING (they are meant to fan out side by side) but never from crossing or from lying exactly on top of one another, expressed by pricing them at zero crowding clearance through the same shared `polylineConflictCost`. On the reported diagram: mirror-symmetry error drops from 25px to <=5px (grid rounding), worst exit angle 99 -> 68 degrees, length 2258 -> 2231, still 0 node crossings and 6 bends. The tidy-tree layout was verified already symmetric. Tests: mirror-symmetry of routes, an evenly-spaced/no-duplicate-port fan, mirror-symmetry and even fan spacing for the tidy-tree layout, and an explicit overlap-vs-crowding-vs-crossing cost ordering. Full suite 1791 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4AtGeJaSWiGooNonpVcxN
Claudia-Anthropica
left a comment
There was a problem hiding this comment.
@FelixTJDietrich The latest port-coordination commit does not address the five existing migration, obstacle-query, waypoint-gesture, and label-geometry requests. Those threads remain open, and CI now has four related straight-edge E2E regressions, so I’m requesting changes again on this SHA.
… drawing Follow-up on straight-edge routing quality, all four points measured on the reported parse-tree diagram rather than eyeballed. Sibling connectors clearing the same obstacle all turned at the same corner vertex, so a fan arrived at one shared elbow and read as a knot. Each edge now prefers a corner RING chosen from its own coordinated seat: the outermost members of a fan stand further off, so the detours nest instead of stacking. The key is the seat's distance from its side's centre, compared against the largest such distance on that side — mirror-invariant (a rank-keyed rule would nest the two halves of a symmetric diagram differently) and free of a magic threshold, which the raw ratios would trip over since they depend on side length and seat count. An order-dependent "penalise an already-used elbow" rule was tried first and rejected: it untied the knot but resolved the two halves differently, costing the symmetry that matters more. It survives only as a small tie-break. Edge-to-edge clearance now uses the node clearance rather than the engine's tighter parallel band: a straight route can sit anywhere, so it can afford real daylight. Because the crowding charge falls to exactly zero at that separation, neatly parallel connectors are the cheapest arrangement available rather than a merely tolerated one — a genuine reward, expressed as a penalty that vanishes, since a literal bonus would be a negative edge weight and A* may not have those. Sides come from `facingSide` again rather than a raw-direction variant. Weighing the partner direction against the node's own half-extents is what makes a row of children all attach on their TOP edge instead of some catching a corner and arriving sideways — the reported `exp -> exp` case — and it keeps a sibling fan reading as parallel. Stability: a bend is now priced at four orthogonal corners. A diagonal bend can fall anywhere, so a small floor let the search buy a corner for a few pixels of length — which is exactly what makes a drawing reorganise itself when a node is nudged. Under a one-grid-cell move of every node in the fixture, topology changes fall from 28 to 15 (bend-count flips 18 -> 5) with byte-identical routes on all four reported diagrams. Tests: nested (non-shared) sibling elbows, and topology invariance under a one-grid-cell nudge in six directions. Full suite 1793 green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4AtGeJaSWiGooNonpVcxN
Claudia-Anthropica
left a comment
There was a problem hiding this comment.
@FelixTJDietrich The latest detour-nesting and stability changes do not address the five existing migration, obstacle-query, waypoint-gesture, and label-geometry requests. Those threads remain open, so I’m requesting changes again on this SHA.
… alike The reported "right side is off, like stmt -> }": a symmetric diagram was drawn lopsided. Its left flank nested its two detours on different corner rings while the right flank collapsed both onto one shared elbow. Root cause is a floating-point comparison on a decision path — the exact hazard this subsystem's "integer, exactly computed" rule exists to prevent, introduced by the ring selection added in the previous commit. A seat and its mirror image lie the same distance from their side's centre in exact arithmetic but not in binary floating point: for a seven-way band, 0.5 - 1/7 = 0.35714285714285715 6/7 - 0.5 = 0.3571428571428571 (smaller in the final bit) Ring choice compared each seat's distance against the largest on its side, so the right-hand member of every mirror pair failed the test by one ulp, lost the roomy ring, and fell back onto the tight corner its neighbour already used. Rounding the distance to whole permille before comparing makes reflection exact — far finer than any seat spacing, far coarser than the artefact. Both flanks now nest identically: bends land at (30,360)/(45,375) and mirror to (350,360)/(335,375). Covered by a regression test that fails on the previous arithmetic. Known remaining limitation, unchanged by this fix: when many edges share one short node side (five on a 70px side seats them 12px apart) a fan leaves the node too narrow to read as separate lines until the routes have travelled some distance. Widening it means letting a crowded side overflow onto its neighbours, which is a change to the shared port-assignment stage rather than to straight edges alone. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4AtGeJaSWiGooNonpVcxN
Claudia-Anthropica
left a comment
There was a problem hiding this comment.
@FelixTJDietrich The symmetric port-seat fix is focused, but this commit does not address the five open migration, obstacle-query, waypoint-gesture, and label-geometry requests. Those threads remain unresolved, so I’m requesting changes again on this SHA.
…waypoints Two problems, one measured and one about the interaction model. A fan of straight edges could CROSS ITSELF: on the reported diagrams `stmt -> exp` crossed both `stmt -> ;` and `stmt -> }`. Root cause is in port ordering, not in the routing cost. `assignPorts` orders seats with `crossingOrderKey`, which for a partner beyond the node's own band deliberately INVERTS the angular order so a farther-reaching route nests outside a nearer one — correct for an orthogonal route, which leaves along an axis and turns far away. A straight edge runs directly at its partner, so its crossing-free order is simply the angular one, and the inversion manufactures the very crossing it exists to prevent: a near partner gets seated past a far one and the two rays must cross. Straight ends now sort on `alongSideKey`; step edges are untouched. On punishing contact harder: tried, measured, rejected. Scaling the shared crossing/overlap/crowding cost for straight edges makes the drawings WORSE, because the router's only lever is bending around obstacle corners — at 2x one fixture goes from 6 bends to 10 and its closest non-sibling approach from 35px to 0 (extra bends land on shared corners), and at 4x from 6 to 19 bends with 41 pairs closer than 25px. The shared weights stay. Left as a named `CONFLICT_SEVERITY` of 1 with that finding recorded, so the next person does not repeat the experiment. Handles: a straight edge has no segment to slide along a fixed axis, so the step edge's elongated bend pill was the wrong metaphor — it is a polyline through POINTS and editing it means picking a point up. Waypoints now render as round grips on the points themselves with a move cursor, and EVERY interior vertex of the rendered route carries one, including bends the router placed: dragging an automatic bend is how a user takes ownership of a computed route. Segment midpoints keep a faint half-opacity grip of the same shape — the same affordance, not real until dragged. Full suite 1794 green; lint, build, size and knip pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H4AtGeJaSWiGooNonpVcxN
Claudia-Anthropica
left a comment
There was a problem hiding this comment.
@FelixTJDietrich The rendered-route waypoint and live handle-preview fixes address two of the five requests. The 4.x migration, distant-checkpoint obstacle query, and bent include/extend and label geometry threads are still open, so I’m requesting changes on this SHA.
Summary
Brings the straight-hook edge regime (UML use-case associations/include/extend/generalization, syntax-tree links, petri-net arcs) to parity with the orthogonal step-edge engine from #826. These edges were dumb 2-point diagonal lines with no bend handles, no auto-layout, and no obstacle avoidance. They now support hand-editing, automatic node avoidance, and — for syntax trees — a one-click tidy layout.
Closes #392. Addresses #282 (both the literal "add a joint" ask and its root cause) and lands a first concrete slice of #128.
Straight-hook edges are diagonal by UML convention — this is deliberately not "make them orthogonal". It is a parallel straight-polyline regime that shares the engine's contracts (memoryless, deterministic, preview==commit) and cost vocabulary, but not the orthogonal grid-A* search or the H/V jog machinery.
Track A — manual waypoints ("bend handles to pin it")
data.pointsholds interior waypoints only (JointJS "vertices" model); the route is[source, ...points, target]connected by plain diagonal segments. Documented divergence from step edges (which store the full route).path(full polyline incl. endpoints, previously inert for these types) is cleared on import so old diagrams don't sprout spurious/double bends.Track B — syntax-tree tidy layout (#282)
SyntaxTreeLinkedges and repositions nodes so parent→child links no longer overlap siblings — the root-cause fix for Syntax tree diagram: long text overlaps nodes (needs line wrapping) #282 (a node-separation problem, not routing).ApollonEditor.layoutSyntaxTree()and a SyntaxTree-only builtin control.Track D — automatic obstacle-avoiding straight router
isqrt(exact floor) for length, integer dot/cross turn buckets for the angle penalty; nohypot/atan2/float on any decision path. Canonical vertex/heap keys keep the search byte-identical across Yjs peers and reloads. ReusespolylineConflictCostand the obstacle corridor.Design & bundle
Budgets were raised deliberately (they are a guardrail, not a physical ceiling) with headroom, rather than distorting the architecture to fit an arbitrary number: main entry 118→126 kB (measured 122.06), routing worker 40→44 kB (measured 39.95).
/modeland/exportunchanged.Implementation notes
New modules:
utils/geometry/freeWaypoints.ts,utils/geometry/integerGeometry.ts,utils/geometry/straightPolylineRouter.ts,utils/tidyTree/{tidyTree,buildForest,applyTidyLayout}.ts. The design was pressure-tested from four independent angles (codebase map, OSS/industry survey, principal-engineer review, product devil's-advocate) before implementation; a cost-model attachment-selection track was deliberately cut as subsumed by Track D and determinism-unsafe on the use-case ellipse.Steps for testing
Tests
Unit:
freeWaypoints,integerGeometry,straightPolylineRouter(incl. shuffled-input determinism),tidyTree,tidyForest,straightHookRouting(solver S1 passthrough + Track D obstacle bend + cold-solve determinism), and a v3-migration case. Full suite: 1783 passed / 1 skipped.lint,format:check,build(all packages),size,knipall green.Checklist
feattype matches the primary user-visible changepnpm lint && pnpm format:check && pnpm build && pnpm test— green🤖 Generated with Claude Code