Skip to content

Commit d9a287b

Browse files
Add support for sticky Manhattan Routing and rounded Edges (#486)
* Add sticky Manhattan edge router Preserves intermediate bend points when a connected node moves and only slides the corner adjacent to the moved endpoint, instead of recomputing the full route like the standard Manhattan router does. Ported from Ralph Soika's `BPMNManhattanRouter` in Open-BPMN, see eclipse-glsp/glsp#1642. Opt-in per edge via `edge.routerKind = 'sticky-manhattan'`. Per-edge state lives in a `WeakMap` keyed on the edge instance. Dedicated anchor computers are registered so the router coexists with the standard Manhattan router. Adds `Point.isVertical` and `Point.isHorizontal` helpers to the existing `sprotty-geometry-point` augmentation so axis-alignment checks read as one-liners across the router. * Add rounded-corner edge view `RoundedCornerEdgeView` extends `GEdgeView` and overrides `createPathForSegments` to emit SVG quadratic curves at every right-angle bend, producing smooth corners on Manhattan-style routes. The radius is clamped per corner to a fraction of the shorter adjacent segment to avoid visual artifacts on short segments. Ported from Ralph Soika's `BPMNEdgeView`, contributed alongside the sticky Manhattan router via the same discussion. The view works with any router that emits right-angle corners; combining it with `GLSPStickyManhattanEdgeRouter` reproduces the look-and-feel of the original Open-BPMN setup. * [DO NOT MERGE] Wire workflow example to sticky router and rounded view Temporary reviewer aid: routes every workflow edge through the `sticky-manhattan` router and renders it with `RoundedCornerEdgeView`, so reviewers can drag connected nodes and observe bend-point preservation and rounded-corner rendering in the standalone example. Revert before merging to master. * Address PR #486 review feedback - Mark `GLSPStickyManhattanEdgeRouter` as `@experimental` in the JSDoc: API and implementation may change in future releases. - Drop the trivial `KIND` constant test and the anchor-registration DI wiring test from the sticky-router spec — the first exercises a string literal, the second tests `routingModule` hookup rather than router behavior. - Trim the noisy historical comment in `applyFollowLogic` down to the single sentence that actually explains the current behavior. - Delete `rounded-corner-edge-view.spec.ts`: per review, unit-testing views on a string-render level is brittle and diverges from the rest of the repository, which covers views via e2e tests. - Introduce `axisTolerance` on `StickyManhattanRouterOptions` (defaulting to `1` px) and thread it through the sticky-router call sites to `Point.isVertical` / `Point.isHorizontal`. `manhattanify` now takes the `edge` so it resolves the tolerance via `getOptions`, consistent with the other protected helpers. - Change the `Point.isVertical` / `Point.isHorizontal` default tolerance from `1` to the new `ALMOST_EQUAL_EPSILON` constant (`1e-3`), matching sprotty's `almostEquals` convention. The 1-pixel tolerance is a sticky-router concern and now lives in its options, not as a default on a general-purpose geometry helper. * Sticky-manhattan router: review feedback - Make route() side-effect free; refresh baseline in commitRoute and on persistence cleanup (updateHandles=true) - Preserve sub-pixel precision (drop Math.round) - Clear baseline on fresh-default fallback - Thread DefaultAnchors through follow/cleanup helpers - Note simpler tier-3 fallback vs sprotty * Rounded-corner Manhattan edge view: review feedback - Rename to RoundedCornerManhattanEdgeView; reflect Manhattan-only contract in JSDoc - Tighten right-angle check via Point.isHorizontalAligned / isVerticalAligned - Promote shortSegmentThreshold / shortSegmentRadiusFactor to fields - Drop decorative Math.round(radius * 10) / 10 * Rounded-corner Manhattan view: gaps on intersections - Add GEdgeViewWithGapsOnIntersections: parallel to GEdgeView on top of sprotty's PolylineEdgeViewWithGapsOnIntersections - Extend RoundedCornerManhattanEdgeView from it; merge corner rounding and intersection-path splicing in one builder - Route via edgeRouterRegistry.route(edge, args) so the postprocessor pipeline populates intersection data - Skip gap fragments inside rounded-corner zones to avoid backward strokes into the curve * Fix all * Revert "[DO NOT MERGE] Wire workflow example to sticky router and rounded view" This reverts commit 2de4253.
1 parent 5fbf592 commit d9a287b

13 files changed

Lines changed: 1358 additions & 10 deletions

examples/workflow-glsp/src/model.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/********************************************************************************
2-
* Copyright (c) 2020-2024 EclipseSource and others.
2+
* Copyright (c) 2020-2026 EclipseSource and others.
33
*
44
* This program and the accompanying materials are made available under the
55
* terms of the Eclipse Public License v. 2.0 which is available at

examples/workflow-glsp/src/workflow-diagram-module.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/********************************************************************************
2-
* Copyright (c) 2019-2025 EclipseSource and others.
2+
* Copyright (c) 2019-2026 EclipseSource and others.
33
*
44
* This program and the accompanying materials are made available under the
55
* terms of the Eclipse Public License v. 2.0 which is available at

packages/client/src/features/routing/edge-router.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/********************************************************************************
2-
* Copyright (c) 2024 EclipseSource and others.
2+
* Copyright (c) 2024-2026 EclipseSource and others.
33
*
44
* This program and the accompanying materials are made available under the
55
* terms of the Eclipse Public License v. 2.0 which is available at
@@ -114,7 +114,7 @@ export class GLSPBezierEdgeRouter extends BezierEdgeRouter {
114114
}
115115
}
116116

117-
function ensureBounds(element?: GConnectableElement): boolean {
117+
export function ensureBounds(element?: GConnectableElement): boolean {
118118
if (!element) {
119119
return false;
120120
}

packages/client/src/features/routing/routing-module.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/********************************************************************************
2-
* Copyright (c) 2019-2024 EclipseSource and others.
2+
* Copyright (c) 2019-2026 EclipseSource and others.
33
*
44
* This program and the accompanying materials are made available under the
55
* terms of the Eclipse Public License v. 2.0 which is available at
@@ -32,6 +32,8 @@ import {
3232
configureCommand
3333
} from '@eclipse-glsp/sprotty';
3434
import { GLSPBezierEdgeRouter, GLSPManhattanEdgeRouter, GLSPPolylineEdgeRouter } from './edge-router';
35+
import { StickyManhattanDiamondAnchor, StickyManhattanEllipticAnchor, StickyManhattanRectangularAnchor } from './sticky-manhattan-anchors';
36+
import { GLSPStickyManhattanEdgeRouter } from './sticky-manhattan-edge-router';
3537

3638
export const routingModule = new FeatureModule(
3739
(bind, unbind, isBound, rebind) => {
@@ -54,6 +56,11 @@ export const routingModule = new FeatureModule(
5456
bindAsService(context, TYPES.IAnchorComputer, BezierRectangleAnchor);
5557
bindAsService(context, TYPES.IAnchorComputer, BezierDiamondAnchor);
5658

59+
bindAsService(context, TYPES.IEdgeRouter, GLSPStickyManhattanEdgeRouter);
60+
bindAsService(context, TYPES.IAnchorComputer, StickyManhattanEllipticAnchor);
61+
bindAsService(context, TYPES.IAnchorComputer, StickyManhattanRectangularAnchor);
62+
bindAsService(context, TYPES.IAnchorComputer, StickyManhattanDiamondAnchor);
63+
5764
configureCommand({ bind, isBound }, AddRemoveBezierSegmentCommand);
5865
},
5966
{ featureId: Symbol('routing') }
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/********************************************************************************
2+
* Copyright (c) 2026 EclipseSource and others.
3+
*
4+
* This program and the accompanying materials are made available under the
5+
* terms of the Eclipse Public License v. 2.0 which is available at
6+
* http://www.eclipse.org/legal/epl-2.0.
7+
*
8+
* This Source Code may also be made available under the following Secondary
9+
* Licenses when the conditions for such availability set forth in the Eclipse
10+
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
11+
* with the GNU Classpath Exception which is available at
12+
* https://www.gnu.org/software/classpath/license.html.
13+
*
14+
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
15+
********************************************************************************/
16+
import {
17+
DIAMOND_ANCHOR_KIND,
18+
ELLIPTIC_ANCHOR_KIND,
19+
ManhattanDiamondAnchor,
20+
ManhattanEllipticAnchor,
21+
ManhattanRectangularAnchor,
22+
RECTANGULAR_ANCHOR_KIND
23+
} from '@eclipse-glsp/sprotty';
24+
import { injectable } from 'inversify';
25+
import { GLSPStickyManhattanEdgeRouter } from './sticky-manhattan-edge-router';
26+
27+
// Anchor computers are keyed by `<routerKind>:<anchorKind>` in the AnchorComputerRegistry.
28+
// The sticky router has its own routerKind, so dedicated subclasses must be registered even
29+
// though their geometry behavior is identical to the standard Manhattan anchors.
30+
31+
@injectable()
32+
export class StickyManhattanRectangularAnchor extends ManhattanRectangularAnchor {
33+
static override readonly KIND = GLSPStickyManhattanEdgeRouter.KIND + ':' + RECTANGULAR_ANCHOR_KIND;
34+
override get kind(): string {
35+
return StickyManhattanRectangularAnchor.KIND;
36+
}
37+
}
38+
39+
@injectable()
40+
export class StickyManhattanDiamondAnchor extends ManhattanDiamondAnchor {
41+
static override readonly KIND = GLSPStickyManhattanEdgeRouter.KIND + ':' + DIAMOND_ANCHOR_KIND;
42+
override get kind(): string {
43+
return StickyManhattanDiamondAnchor.KIND;
44+
}
45+
}
46+
47+
@injectable()
48+
export class StickyManhattanEllipticAnchor extends ManhattanEllipticAnchor {
49+
static override readonly KIND = GLSPStickyManhattanEdgeRouter.KIND + ':' + ELLIPTIC_ANCHOR_KIND;
50+
override get kind(): string {
51+
return StickyManhattanEllipticAnchor.KIND;
52+
}
53+
}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
/********************************************************************************
2+
* Copyright (c) 2026 EclipseSource and others.
3+
*
4+
* This program and the accompanying materials are made available under the
5+
* terms of the Eclipse Public License v. 2.0 which is available at
6+
* http://www.eclipse.org/legal/epl-2.0.
7+
*
8+
* This Source Code may also be made available under the following Secondary
9+
* Licenses when the conditions for such availability set forth in the Eclipse
10+
* Public License v. 2.0 are satisfied: GNU General Public License, version 2
11+
* with the GNU Classpath Exception which is available at
12+
* https://www.gnu.org/software/classpath/license.html.
13+
*
14+
* SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0
15+
********************************************************************************/
16+
import { DefaultAnchors, EdgeRouterRegistry, GNode, GRoutableElement, Point, Side } from '@eclipse-glsp/sprotty';
17+
import { expect } from 'chai';
18+
import { Container } from 'inversify';
19+
import { GEdge, GGraph } from '../../model';
20+
import { routingModule } from './routing-module';
21+
import { GLSPStickyManhattanEdgeRouter, StickyManhattanRouterOptions } from './sticky-manhattan-edge-router';
22+
23+
/** Exposes the protected helpers as public so the spec can exercise them directly. */
24+
class TestableStickyManhattanEdgeRouter extends GLSPStickyManhattanEdgeRouter {
25+
public override manhattanify(points: Point[], edge: GRoutableElement): void {
26+
super.manhattanify(points, edge);
27+
}
28+
public override getOptions(edge: GRoutableElement): StickyManhattanRouterOptions {
29+
return super.getOptions(edge);
30+
}
31+
public override getBestConnectionAnchors(
32+
sourceAnchors: DefaultAnchors,
33+
targetAnchors: DefaultAnchors,
34+
options: StickyManhattanRouterOptions
35+
): { source: Side; target: Side } {
36+
return super.getBestConnectionAnchors(sourceAnchors, targetAnchors, options);
37+
}
38+
}
39+
40+
function newNode(id: string, x: number, y: number, width = 40, height = 30): GNode {
41+
const node = new GNode();
42+
node.id = id;
43+
node.position = { x, y };
44+
node.size = { width, height };
45+
return node;
46+
}
47+
48+
function setupEdge(opts: { sourcePos?: Point; targetPos?: Point; routingPoints?: Point[] }): {
49+
graph: GGraph;
50+
edge: GEdge;
51+
router: GLSPStickyManhattanEdgeRouter;
52+
} {
53+
const graph = new GGraph();
54+
const source = newNode('source', opts.sourcePos?.x ?? 0, opts.sourcePos?.y ?? 0);
55+
const target = newNode('target', opts.targetPos?.x ?? 200, opts.targetPos?.y ?? 100);
56+
graph.add(source);
57+
graph.add(target);
58+
59+
const edge = new GEdge();
60+
edge.id = 'edge';
61+
edge.sourceId = 'source';
62+
edge.targetId = 'target';
63+
edge.routerKind = GLSPStickyManhattanEdgeRouter.KIND;
64+
if (opts.routingPoints) {
65+
edge.routingPoints = opts.routingPoints.slice();
66+
}
67+
graph.add(edge);
68+
69+
const container = new Container();
70+
container.load(routingModule);
71+
const registry = container.get<EdgeRouterRegistry>(EdgeRouterRegistry);
72+
const router = registry.get(GLSPStickyManhattanEdgeRouter.KIND) as GLSPStickyManhattanEdgeRouter;
73+
return { graph, edge, router };
74+
}
75+
76+
describe('GLSPStickyManhattanEdgeRouter', () => {
77+
describe('route()', () => {
78+
it('returns an empty route when the source node cannot be resolved', () => {
79+
const graph = new GGraph();
80+
const target = newNode('target', 200, 100);
81+
graph.add(target);
82+
const edge = new GEdge();
83+
edge.id = 'edge';
84+
edge.sourceId = 'missing';
85+
edge.targetId = 'target';
86+
edge.routerKind = GLSPStickyManhattanEdgeRouter.KIND;
87+
graph.add(edge);
88+
89+
const container = new Container();
90+
container.load(routingModule);
91+
const router = container
92+
.get<EdgeRouterRegistry>(EdgeRouterRegistry)
93+
.get(GLSPStickyManhattanEdgeRouter.KIND) as GLSPStickyManhattanEdgeRouter;
94+
expect(router.route(edge)).to.deep.equal([]);
95+
});
96+
97+
it('produces a source-first, target-last sequence with intermediate linear points', () => {
98+
const { edge, router } = setupEdge({});
99+
const route = router.route(edge);
100+
expect(route[0].kind).to.equal('source');
101+
expect(route[route.length - 1].kind).to.equal('target');
102+
expect(route.slice(1, -1).every(p => p.kind === 'linear')).to.equal(true);
103+
});
104+
105+
it('computes a two-corner default route for horizontally separated nodes', () => {
106+
const { edge, router } = setupEdge({ sourcePos: { x: 0, y: 0 }, targetPos: { x: 200, y: 100 } });
107+
const route = router.route(edge);
108+
// source RIGHT -> target LEFT with different Y: two corners at midX.
109+
const interior = route.slice(1, -1);
110+
expect(interior).to.have.lengthOf(2);
111+
expect(interior[0].x).to.equal(interior[1].x);
112+
expect(interior[0].y).to.not.equal(interior[1].y);
113+
});
114+
});
115+
116+
describe('sticky behavior', () => {
117+
it('preserves interior bend points when the source node moves vertically', () => {
118+
const { edge, router } = setupEdge({
119+
sourcePos: { x: 0, y: 0 },
120+
targetPos: { x: 300, y: 200 },
121+
routingPoints: [
122+
{ x: 150, y: 15 },
123+
{ x: 150, y: 215 }
124+
]
125+
});
126+
127+
// Prime the position snapshot.
128+
router.route(edge);
129+
130+
// Move the source node down by 50px.
131+
edge.source!.position = { x: 0, y: 50 };
132+
const route = router.route(edge);
133+
const interior = route.slice(1, -1);
134+
135+
// The shared x=150 spine must stay put — no recomputed midX.
136+
expect(interior.every(p => p.x === 150)).to.equal(true);
137+
// The target-side bend must not have moved.
138+
expect(interior[interior.length - 1].y).to.equal(215);
139+
});
140+
});
141+
142+
describe('cleanupRoutingPoints()', () => {
143+
it('removes leading routing points that fall inside the source bounds', () => {
144+
const { edge, router } = setupEdge({
145+
sourcePos: { x: 0, y: 0 },
146+
targetPos: { x: 300, y: 100 },
147+
routingPoints: [
148+
{ x: 10, y: 10 }, // inside source bounds (40x30 at origin)
149+
{ x: 150, y: 20 },
150+
{ x: 150, y: 110 }
151+
]
152+
});
153+
const points = edge.routingPoints.slice();
154+
router.cleanupRoutingPoints(edge, points, false, false);
155+
expect(points).to.not.deep.include({ x: 10, y: 10 });
156+
expect(points[0]).to.deep.equal({ x: 150, y: 20 });
157+
});
158+
159+
it('collapses degenerate segments shorter than minimalPointDistance', () => {
160+
const { edge, router } = setupEdge({ sourcePos: { x: 0, y: 0 }, targetPos: { x: 300, y: 100 } });
161+
const points: Point[] = [
162+
{ x: 100, y: 20 },
163+
{ x: 101, y: 21 }, // manhattan distance 2 < default minimal of 3
164+
{ x: 250, y: 20 }
165+
];
166+
router.cleanupRoutingPoints(edge, points, false, false);
167+
expect(points).to.have.lengthOf(1);
168+
expect(points[0]).to.deep.equal({ x: 250, y: 20 });
169+
});
170+
});
171+
172+
describe('manhattanify()', () => {
173+
it('inserts an intermediate corner so every segment is strictly orthogonal', () => {
174+
const { edge, router } = setupEdge({});
175+
const testable = router as TestableStickyManhattanEdgeRouter;
176+
const points: Point[] = [
177+
{ x: 0, y: 0 },
178+
{ x: 50, y: 50 } // diagonal
179+
];
180+
testable.manhattanify(points, edge);
181+
expect(points).to.deep.equal([
182+
{ x: 0, y: 0 },
183+
{ x: 0, y: 50 },
184+
{ x: 50, y: 50 }
185+
]);
186+
});
187+
188+
it('leaves strictly orthogonal routes untouched', () => {
189+
const { edge, router } = setupEdge({});
190+
const testable = router as TestableStickyManhattanEdgeRouter;
191+
const points: Point[] = [
192+
{ x: 0, y: 0 },
193+
{ x: 50, y: 0 },
194+
{ x: 50, y: 40 }
195+
];
196+
const before = points.map(p => ({ ...p }));
197+
testable.manhattanify(points, edge);
198+
expect(points).to.deep.equal(before);
199+
});
200+
});
201+
202+
describe('getBestConnectionAnchors()', () => {
203+
it('picks RIGHT/LEFT when source is clearly to the left of target', () => {
204+
const { edge } = setupEdge({ sourcePos: { x: 0, y: 0 }, targetPos: { x: 400, y: 0 } });
205+
const router = new TestableStickyManhattanEdgeRouter();
206+
const sourceAnchors = new DefaultAnchors(edge.source!, edge.parent, 'source');
207+
const targetAnchors = new DefaultAnchors(edge.target!, edge.parent, 'target');
208+
const result = router.getBestConnectionAnchors(sourceAnchors, targetAnchors, router.getOptions(edge));
209+
expect(result).to.deep.equal({ source: Side.RIGHT, target: Side.LEFT });
210+
});
211+
});
212+
});

0 commit comments

Comments
 (0)