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
8 changes: 7 additions & 1 deletion docs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@ Every write updates the diagram. `--layout <mode>` says how; `--relayout` is `--
| `full` | the engine redraws everything; colours and DI ids are kept, positions are not |

So a drawing you formatted by hand, here or in a modeler, stays as it is. A format command that moves
a shape or reroutes a flow makes the drawing hand-made, so your formatting persists. If an
a shape or reroutes a flow makes the drawing hand-made, so your formatting persists.

Room is made only where it is needed: what follows the new element moves right, with what it would
run into on the way, and by no more than the room that is missing. Another row of the diagram keeps
its place, so a task added to a rejection path leaves the main path above it alone. A new flow that
would enter an element left of its source (not a loop return) moves that element and what follows it
right of the source. If an
incremental update fails, `auto` falls back to a full redraw (`W_LAYOUT_INCREMENTAL_FAILED`).

<figure class="diagram">
Expand Down
6 changes: 3 additions & 3 deletions plugins/bpmn-cli/skills/bpmn/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,9 +237,9 @@ Details, every op key and worked batches: [reference/ops.md](reference/ops.md).

- `auto` (default): a new file or a drawing exactly as the engine would draw it
is redrawn in full; anything hand-made or formatted is **kept** - new
elements are placed locally, room is made like the modeler's space tool,
only affected flows are rerouted (and a line that does not reach its ends,
`detachedEdge`, is routed again).
elements are placed locally, room is made for what follows them (other rows
stay), only affected flows are rerouted (and a line that does not reach its
ends, `detachedEdge`, is routed again).
- `--layout incremental`: always keep (fails with `E_LAYOUT_INCREMENTAL`
instead of redrawing) - use it when keeping the drawing is a requirement.
- `--relayout` (= `--layout full`, `bpmn layout <file>`): redraw everything;
Expand Down
6 changes: 4 additions & 2 deletions src/adapters/in/report/AgentGuide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,11 +266,13 @@ export class AgentGuide {
[
' auto (default) no diagram yet, or the drawing is exactly what the engine would draw (nobody changed it):',
' redraw in full. Otherwise keep it: new elements are placed next to their neighbours, room is',
' made like the modeler\'s space tool, only affected flows are rerouted, removed elements\' DI pruned.',
' made for what follows them (and what that runs into; other rows stay), only affected flows are',
' rerouted, removed elements\' DI pruned.',
' A line that does not reach an end of its own (left at an old place by an older --no-layout',
' write or another tool) is routed again and listed as rerouted.',
' A batch places its new nodes like the same ops sent one by one (exception paths after the',
' normal flow); a `connect` that would enter a merge backwards moves the merge after its sources.',
' normal flow); a flow that would enter a node backwards (no loop return) moves it and what',
' follows it right of its source (a merge: after all its sources).',
' A new branch / boundary path whose rows below cross a line opens the row right under its anchor',
' (the line moves down), or goes above it (a boundary event the CLI placed then on the top border;',
' one drawn by hand keeps its border; the path of an event on the top border runs above its host);',
Expand Down
11 changes: 6 additions & 5 deletions src/domain/diagram/di/PlaneQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,15 @@ export class PlaneQuery {
return [...plane.shapes.values()].filter((shape) => shape.hostId === hostId);
}

/** Gap and row spacing of a plane, measured on its sequence flows. */
static spacingOf(plane: Plane): Spacing {
/** Gap and row spacing of a plane, measured on its sequence flows (between the ends `endsOf` names; none: skipped). */
static spacingOf(plane: Plane, endsOf: (edge: DEdge) => Pick<DEdge, 'sourceId' | 'targetId'> | undefined = (edge) => edge): Spacing {
const gaps: number[] = [];
const rows: number[] = [];
for (const edge of plane.edges.values()) {
if (edge.kind !== 'sequenceFlow' || !edge.sourceId || !edge.targetId) continue;
const source = plane.shapes.get(edge.sourceId);
const target = plane.shapes.get(edge.targetId);
const ends = edge.kind === 'sequenceFlow' ? endsOf(edge) : undefined;
if (!ends?.sourceId || !ends.targetId) continue;
const source = plane.shapes.get(ends.sourceId);
const target = plane.shapes.get(ends.targetId);
if (!source || !target || source.kind === 'boundary' || source.container || target.container) continue;
const dy = Math.abs(cy(source.bounds) - cy(target.bounds));
if (dy <= 5) {
Expand Down
32 changes: 27 additions & 5 deletions src/domain/diagram/layout/incremental/CrossingRepair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { LayoutDebug } from '../LayoutDebug.js';
import { LayoutAnalysis } from '../../di/LayoutAnalysis.js';
import { TrialGuard, type TrialPolicy } from '../../di/TrialGuard.js';
import type { DEdge, Plane, Point } from '../../di/types.js';
import { copyBox, polylinesCross, samePoints } from '../../utils/geom.js';
import { copyBox, polylinesCross, samePoints, toSegments } from '../../utils/geom.js';
import { collinear } from '../../utils/harness.js';
import { raw } from '../../utils/raw.js';
import { isHardProblem, problemKey, type LayoutMetrics } from '../../utils/problems.js';
import { PlaneWriter } from '../../di/PlaneWriter.js';
Expand All @@ -14,10 +15,14 @@ const REPAIR_TRIALS = 8;

const CHAIN_RETRIES = 2;

/** px a rerouted hand-drawn line may run along more of the others, the length of a parallel run */
const RUN_SLACK = 20;

/**
* Removes crossings between connections drawn in this run and existing ones: first by rerouting,
* then by placing the new chain at its next row candidate. A trial stays only when it lowers the
* score and adds no hard problem and no crossing among the hand-drawn lines.
* score and adds no hard problem and no crossing among the hand-drawn lines. A hand-drawn line is
* not laid on top of the others to save a crossing: it would read as their continuation.
*/
export class CrossingRepair {
constructor(private readonly run: IncrementalLayout) {}
Expand Down Expand Up @@ -80,7 +85,10 @@ export class CrossingRepair {
return undefined;
}

/** A route that still crosses is skipped without measuring, as the guard would reject it anyway. */
/**
* A route that still crosses is skipped without measuring, as the guard would reject it anyway; so
* is one that lays a hand-drawn line along more of the others.
*/
async repair(rerouted: string[], start: Checkpoint, redraw: () => Promise<string[]>): Promise<string[]> {
if (!this.newCrossings().length) return rerouted;
let trials = 0;
Expand Down Expand Up @@ -113,8 +121,9 @@ export class CrossingRepair {
const pts = drawing.route(target, { lines: drawing.routingLines(target.id) });
if (!pts || pts.length < 2 || samePoints(pts, target.points)) continue;
const other = target === freshEdge ? keptEdge : freshEdge;
if (polylinesCross(pts, other.points) || (target === keptEdge && crossesNewDrawnLine(plane, target, pts))) {
if (LayoutDebug.on()) LayoutDebug.log(`[repair] ${target.id} routed again (it crossed: ${freshEdge.id} x ${keptEdge.id}): still crossing, not measured`);
const crossing = polylinesCross(pts, other.points) || (target === keptEdge && crossesNewDrawnLine(plane, target, pts));
if (crossing || (target === keptEdge && runsAlongDrawnLines(plane, target, pts))) {
if (LayoutDebug.on()) LayoutDebug.log(`[repair] ${target.id} routed again (it crossed: ${freshEdge.id} x ${keptEdge.id}): ${crossing ? 'still crossing' : 'along drawn lines'}, not measured`);
continue;
}
trials++;
Expand All @@ -137,6 +146,19 @@ export class CrossingRepair {
};
const crossesNewDrawnLine = (plane: Plane, target: DEdge, pts: readonly Point[]): boolean =>
[...plane.edges.values()].some((other) => other !== target && !this.run.newEdges.has(other.id) && other.points.length >= 2 && polylinesCross(pts, other.points) && !polylinesCross(target.points, other.points));
/** collinear with more of the drawn lines that share no end with the edge than its points are, measured like a parallel run */
const runsAlongDrawnLines = (plane: Plane, target: DEdge, pts: Point[]): boolean => {
const along = (points: Point[]): number => {
const segs = toSegments(points);
let len = 0;
for (const other of plane.edges.values()) {
if (other === target || this.run.newEdges.has(other.id) || [other.sourceId, other.targetId].some((end) => end === target.sourceId || end === target.targetId)) continue;
for (const sb of toSegments(other.points)) for (const sa of segs) len += Math.max(0, collinear(sa, sb));
}
return len;
};
return along(pts) > along(target.points) + RUN_SLACK;
};
reroutes(rerouted);
const tried = new Map<string, number>();
while (trials < REPAIR_TRIALS && retries < CHAIN_RETRIES && !this.run.fromEngine.size && this.run.planes.length === start.planes.length) {
Expand Down
18 changes: 13 additions & 5 deletions src/domain/diagram/layout/incremental/Drawing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { DrawingSnapshot } from './DrawingSnapshot.js';
import type { TrialGuard } from '../../di/TrialGuard.js';
import type { Box, DEdge, DShape, Plane, Point, Side } from '../../di/types.js';
import type { Semantics } from '../../di/Semantics.js';
import { segmentHits, translateEdge } from '../../utils/geom.js';
import { pathHits, translateEdge } from '../../utils/geom.js';
import { hasExternalLabel, labelSideOf, type LabelSide } from '../../utils/labels.js';
import { raw } from '../../utils/raw.js';

Expand All @@ -24,12 +24,14 @@ export class Drawing {
private readonly separation: Separation;
private readonly frames: FrameEditor;

/** `fresh`: the nodes placed in this run (EdgeRouter) */
constructor(
readonly plane: Plane,
readonly touched: Set<string> = new Set(),
fresh?: ReadonlySet<string>,
) {
this.space = new SpaceTool(plane);
this.routes = new EdgeRouter(plane);
this.routes = new EdgeRouter(plane, fresh);
this.labels = new LabelPlacer(plane);
this.separation = new Separation(plane, this.space);
this.frames = new FrameEditor(plane, this.space, touched);
Expand Down Expand Up @@ -103,6 +105,14 @@ export class Drawing {
return this.routes.detachedEnds(edge);
}

grazes(edge: DEdge, ids: Iterable<string>): boolean {
return this.routes.grazes(edge, ids);
}

touchesFresh(edge: DEdge): boolean {
return this.routes.touchesFresh(edge);
}

reroute(edge: DEdge, opts: { lines?: Point[][]; sourceSides?: Side[]; targetSides?: Side[] } = {}): boolean {
const { lines, ...sides } = opts;
const pts = this.routes.routeEdge(edge, { lines: lines ?? this.routes.routingLines(edge.id), ...sides });
Expand Down Expand Up @@ -137,10 +147,8 @@ export class Drawing {
}

clearLabels(fresh: readonly DEdge[]): void {
const segs = fresh.flatMap((edge) => edge.points.slice(1).map((end, index) => [edge.points[index]!, end] as const));
if (!segs.length) return;
for (const shape of this.plane.shapes.values()) {
if (!shape.label || !hasExternalLabel(shape) || !segs.some(([start, end]) => segmentHits(start, end, shape.label!, 1))) continue;
if (!shape.label || !hasExternalLabel(shape) || !fresh.some((edge) => pathHits(edge.points, shape.label!, 1))) continue;
const host = shape.hostId ? this.plane.shapes.get(shape.hostId)?.bounds : undefined;
const side = labelSideOf(shape, host);
this.labels.placeShapeLabel(shape, side === 'inside' ? undefined : side);
Expand Down
91 changes: 69 additions & 22 deletions src/domain/diagram/layout/incremental/EdgeRouter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { attachedSide, defaultSides, gatewaySide } from '../../utils/sides.js';
import { crossings } from '../../utils/routing.js';
import { BEND, crossings } from '../../utils/routing.js';
import { OrthogonalRouter, type RouteRequest } from './OrthogonalRouter.js';
import type { Side, DEdge, DShape, Plane } from '../../di/types.js';
import { borderDistance, borderPoint, containsPoint, cx, cy, degenerate, isOrthogonal, pathHits, roundPoints, type Box, type Point } from '../../utils/geom.js';
Expand Down Expand Up @@ -27,9 +27,22 @@ export interface EdgeBefore {
/** The `detachedEdge` metric uses the same gap. */
export const DETACHED_GAP = 10;

/** Routes and repairs single connections inside a kept drawing. */
/** An association passing this close to a node placed in this run or its label reads as touching it. */
const GRAZE_GAP = 3;

/** A dock route that cuts a shape loses to any that cuts none, one more crossing to any length. */
const DOCK_CUT = 1e9;
const DOCK_CROSSING = 1e6;

/**
* Routes and repairs single connections inside a kept drawing; `fresh`: the nodes placed in this
* run, which associations keep clear of.
*/
export class EdgeRouter {
constructor(private readonly plane: Plane) {}
constructor(
private readonly plane: Plane,
private readonly fresh: ReadonlySet<string> = new Set(),
) {}

private ancestors(shape: DShape): Set<string> {
const out = new Set<string>();
Expand Down Expand Up @@ -142,10 +155,12 @@ export class EdgeRouter {
}

/**
* The nearest point of the flow a straight line from the shape reaches (`clear`),
* else the middle of the nearest segment, entered across it (`sides`).
* The nearest point of the flow a straight line of `edge` from the shape reaches (no `points`),
* else the route into the middle of a segment, entered across it, that cuts the fewest shapes,
* then crosses the fewest lines, then is the shortest (150 per bend); ties keep the segment
* nearer the shape.
*/
private flowDock(flow: DEdge, from: Box, obstacles: readonly Box[]): { at: Box; clear: boolean; sides?: Side[] } | undefined {
private flowDock(edge: DEdge, flow: DEdge, from: Box, obstacles: Box[], lines: Point[][]): { at: Box; points?: Point[] } | undefined {
const center = { x: cx(from), y: cy(from) };
const cands: Point[] = [];
for (let index = 0; index + 1 < flow.points.length; index++) {
Expand All @@ -158,18 +173,25 @@ export class EdgeRouter {
const dist = (point: Point): number => Math.hypot(point.x - center.x, point.y - center.y);
const box = (point: Point): Box => ({ x: Math.round(point.x), y: Math.round(point.y), width: 0, height: 0 });
const sorted = cands.sort((first, second) => dist(first) - dist(second) || first.x - second.x || first.y - second.y);
for (const candidate of sorted) if (!obstacles.some((obstacle) => pathHits(this.straight(from, box(candidate)), obstacle))) return { at: box(candidate), clear: true };
let best: { at: Box; clear: boolean; sides?: Side[] } | undefined;
let bestD = Infinity;
for (const candidate of sorted) {
const line = this.straight(from, box(candidate));
if (!obstacles.some((obstacle) => pathHits(line, obstacle)) && !this.touchesFresh(edge, line)) return { at: box(candidate) };
}
let best: { at: Box; points: Point[] } | undefined;
let least = Infinity;
for (let index = 0; index + 1 < flow.points.length; index++) {
const start = flow.points[index]!;
const end = flow.points[index + 1]!;
if (Math.abs(start.x - end.x) + Math.abs(start.y - end.y) < 20 && flow.points.length > 2) continue;
const distance = dist(this.nearestOn([start, end], center)!);
if (distance >= bestD) continue;
bestD = distance;
const horizontal = Math.abs(start.x - end.x) >= Math.abs(start.y - end.y);
best = { at: box({ x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 }), clear: false, sides: horizontal ? ['top', 'bottom'] : ['left', 'right'] };
const dx = Math.abs(start.x - end.x);
const dy = Math.abs(start.y - end.y);
if (dx + dy < 20 && flow.points.length > 2) continue;
const at = box({ x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 });
const points = roundPoints(OrthogonalRouter.route({ source: from, target: at, obstacles, lines, targetSides: dx >= dy ? ['top', 'bottom'] : ['left', 'right'] }));
let cost = DOCK_CUT * obstacles.filter((obstacle) => pathHits(points, obstacle)).length + DOCK_CROSSING * crossings(points, lines) + BEND * (points.length - 2) + dist(this.nearestOn([start, end], center)!) / DOCK_CROSSING;
for (let step = 1; step < points.length; step++) cost += Math.abs(points[step]!.x - points[step - 1]!.x) + Math.abs(points[step]!.y - points[step - 1]!.y);
if (cost >= least) continue;
best = { at, points };
least = cost;
}
return best;
}
Expand Down Expand Up @@ -220,17 +242,14 @@ export class EdgeRouter {
const flow = !source ? this.plane.edges.get(edge.sourceId ?? '') : !target ? this.plane.edges.get(edge.targetId ?? '') : undefined;
if (shape && flow && flow.points.length >= 2) {
const obstacles = this.obstaclesFor(edge, shape);
const dock = this.flowDock(flow, shape.bounds, obstacles);
if (dock?.clear) return source ? this.straight(source.bounds, dock.at) : this.straight(dock.at, shape.bounds);
if (dock) {
const pts = roundPoints(OrthogonalRouter.route({ source: shape.bounds, target: dock.at, obstacles, lines: opts.lines, ...(dock.sides ? { targetSides: dock.sides } : {}) }));
return source ? pts : pts.reverse();
}
const dock = this.flowDock(edge, flow, shape.bounds, obstacles, opts.lines);
if (dock?.points) return source ? dock.points : dock.points.reverse();
if (dock) return source ? this.straight(source.bounds, dock.at) : this.straight(dock.at, shape.bounds);
}
const line = this.straight(sb, tb);
if (!source || !target) return line;
const obstacles = this.obstaclesFor(edge, source, target);
if (!obstacles.some((obstacle) => pathHits(line, obstacle))) return line;
if (!obstacles.some((obstacle) => pathHits(line, obstacle)) && !this.touchesFresh(edge, line)) return line;
return roundPoints(OrthogonalRouter.route({ source: source.bounds, target: target.bounds, obstacles, lines: opts.lines }));
}
if (!source || !target) return undefined;
Expand Down Expand Up @@ -280,6 +299,34 @@ export class EdgeRouter {
return out;
}

/**
* Whether the line runs along the border of one of `ids` or touches it within `margin` (reads as
* docking on it); with `labels` their labels count too. Its own ends, their boundary events and
* hosts do not count.
*/
grazes(edge: DEdge, ids: Iterable<string>, { points = edge.points, margin = 1, labels = false }: { points?: readonly Point[]; margin?: number; labels?: boolean } = {}): boolean {
if (points.length < 2) return false;
const own = new Set<string>();
for (const end of [edge.sourceId, edge.targetId]) {
const shape = end ? this.plane.shapes.get(end) : undefined;
if (!shape) continue;
own.add(shape.id);
if (shape.hostId) own.add(shape.hostId);
for (const boundary of PlaneQuery.boundariesOf(this.plane, shape.id)) own.add(boundary.id);
}
for (const id of ids) {
const shape = this.plane.shapes.get(id);
if (!shape || own.has(id) || !PlaneQuery.isLeaf(shape)) continue;
if (pathHits(points, shape.bounds, -margin) || (labels && shape.label && pathHits(points, shape.label, -margin))) return true;
}
return false;
}

/** Whether an association's line touches a node placed in this run or its label. */
touchesFresh(edge: DEdge, points: readonly Point[] = edge.points): boolean {
return (edge.kind === 'association' || edge.kind === 'dataAssociation') && this.grazes(edge, this.fresh, { points, margin: GRAZE_GAP, labels: true });
}

private endGaps(edge: DEdge): { sourceGap: number; targetGap: number } {
const gapOf = (id: string | undefined, point: Point | undefined): number => {
const shape = id ? this.plane.shapes.get(id) : undefined;
Expand Down
Loading
Loading