@ngroznykh/papirus 0.7.3 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/papirus.js CHANGED
@@ -1672,10 +1672,26 @@ var NavigationManager = class extends EventEmitter {
1672
1672
  //#endregion
1673
1673
  //#region src/utils/direction.ts
1674
1674
  /**
1675
- * Map outline param [0,1) to cardinal direction (top → right → bottom → left).
1675
+ * Map outline param [0,1) to cardinal direction.
1676
+ *
1677
+ * When `bounds` are provided, uses the same perimeter walk as
1678
+ * `Node.getConnectionPointAtOutlineParam` (top → right → bottom → left).
1679
+ * Equal-quarter mapping is only a fallback for square-ish shapes / legacy callers —
1680
+ * on wide/short nodes it wrongly labels the left half of the bottom edge as `left`.
1676
1681
  */
1677
- function getDirectionFromOutlineParam(param) {
1682
+ function getDirectionFromOutlineParam(param, bounds) {
1678
1683
  const p = (param % 1 + 1) % 1;
1684
+ const w = bounds?.width ?? 0;
1685
+ const h = bounds?.height ?? 0;
1686
+ if (w > 0 && h > 0) {
1687
+ let s = p * (2 * (w + h));
1688
+ if (s < w) return "top";
1689
+ s -= w;
1690
+ if (s < h) return "right";
1691
+ s -= h;
1692
+ if (s < w) return "bottom";
1693
+ return "left";
1694
+ }
1679
1695
  if (p < .25) return "top";
1680
1696
  if (p < .5) return "right";
1681
1697
  if (p < .75) return "bottom";
@@ -1946,14 +1962,14 @@ var ConnectionManager = class extends EventEmitter {
1946
1962
  snappedPoint = bestResult.point;
1947
1963
  this.reconnectingOutlineParam = bestResult.param;
1948
1964
  this.reconnectingTargetNodeId = bestResult.node.id;
1949
- snappedDir = getDirectionFromOutlineParam(bestResult.param);
1965
+ snappedDir = getDirectionFromOutlineParam(bestResult.param, bestResult.node.getBounds());
1950
1966
  if (this.reconnectingEndpoint === "start" ? toNode : fromNode) {
1951
1967
  const alignRef = edge.isEditablePolyline() ? this.getNearestBendPointForAxisAlignment(edge, this.reconnectingEndpoint) : this.reconnectingEndpoint === "start" ? this.getTargetPointForReconnect(edge, toNode, "to", snappedPoint) : this.getTargetPointForReconnect(edge, fromNode, "from", snappedPoint);
1952
1968
  snappedPoint = this.applyAxisAlignmentToPoint(snappedPoint, alignRef);
1953
1969
  const reprojected = targetNode.getClosestPointOnOutline(snappedPoint);
1954
1970
  snappedPoint = reprojected.point;
1955
1971
  this.reconnectingOutlineParam = reprojected.param;
1956
- snappedDir = getDirectionFromOutlineParam(reprojected.param);
1972
+ snappedDir = getDirectionFromOutlineParam(reprojected.param, targetNode.getBounds());
1957
1973
  }
1958
1974
  } else {
1959
1975
  this.reconnectingOutlineParam = null;
@@ -1976,15 +1992,38 @@ var ConnectionManager = class extends EventEmitter {
1976
1992
  if (toNode) {
1977
1993
  const target = this.getTargetPointForReconnect(edge, toNode, "to", snappedPoint);
1978
1994
  const toDir = this.getTargetDirForReconnect(edge, "to");
1979
- edge.updateEndpoints(snappedPoint, target, snappedDir, toDir);
1995
+ const sourceId = targetNode?.id ?? edge.from.nodeId;
1996
+ edge.updateEndpoints(snappedPoint, target, snappedDir, toDir, this.buildReconnectRoutingOptions(sourceId, toNode.id));
1980
1997
  }
1981
1998
  } else if (fromNode) {
1982
1999
  const start = this.getTargetPointForReconnect(edge, fromNode, "from", snappedPoint);
1983
2000
  const fromDir = this.getTargetDirForReconnect(edge, "from");
1984
- edge.updateEndpoints(start, snappedPoint, fromDir, snappedDir);
2001
+ edge.updateEndpoints(start, snappedPoint, fromDir, snappedDir, this.buildReconnectRoutingOptions(fromNode.id, targetNode?.id));
1985
2002
  }
1986
2003
  this.renderer.markDirty();
1987
2004
  }
2005
+ /**
2006
+ * Match DiagramRenderer.getNodeObstacles padding and EdgeEndpointUpdater roles
2007
+ * so reconnect preview uses the same polyline routing as updateAll.
2008
+ */
2009
+ buildReconnectRoutingOptions(sourceNodeId, targetNodeId) {
2010
+ const obstacles = [];
2011
+ for (const node of this.renderer.nodes.values()) {
2012
+ let role = "other";
2013
+ if (node.id === sourceNodeId) role = "source";
2014
+ else if (node.id === targetNodeId) role = "target";
2015
+ const pad = 4;
2016
+ obstacles.push({
2017
+ id: node.id,
2018
+ x: node.x - pad,
2019
+ y: node.y - pad,
2020
+ width: node.width + pad * 2,
2021
+ height: node.height + pad * 2,
2022
+ role
2023
+ });
2024
+ }
2025
+ return { obstacles };
2026
+ }
1988
2027
  getTargetPointForReconnect(edge, node, endpoint, fallbackPoint) {
1989
2028
  const ep = endpoint === "from" ? edge.from : edge.to;
1990
2029
  if (this.attachToOutline && ep.outlineParam !== void 0) return node.getConnectionPointAtOutlineParam(ep.outlineParam);
@@ -1994,7 +2033,10 @@ var ConnectionManager = class extends EventEmitter {
1994
2033
  }
1995
2034
  getTargetDirForReconnect(edge, endpoint) {
1996
2035
  const ep = endpoint === "from" ? edge.from : edge.to;
1997
- if (this.attachToOutline && ep.outlineParam !== void 0) return getDirectionFromOutlineParam(ep.outlineParam);
2036
+ if (this.attachToOutline && ep.outlineParam !== void 0) {
2037
+ const node = ep.nodeId ? this.renderer.getNode(ep.nodeId) : void 0;
2038
+ return getDirectionFromOutlineParam(ep.outlineParam, node?.getBounds());
2039
+ }
1998
2040
  return (ep.portId?.replace(ANCHOR_PORT_PREFIX, ""))?.split(":")[0];
1999
2041
  }
2000
2042
  /**
@@ -2309,8 +2351,9 @@ var ConnectionManager = class extends EventEmitter {
2309
2351
  ctx.lineTo(end.x, end.y);
2310
2352
  ctx.stroke();
2311
2353
  } else {
2312
- const fromDir = this.sourceOutlineParam !== null ? getDirectionFromOutlineParam(this.sourceOutlineParam) : this.sourceAnchorId?.split(":")[0];
2313
- const toDir = this.previewTargetOutlineParam !== null ? getDirectionFromOutlineParam(this.previewTargetOutlineParam) : this.previewTargetAnchorId?.split(":")[0];
2354
+ const previewTarget = this.previewTargetNodeId != null ? this.renderer.getNode(this.previewTargetNodeId) : void 0;
2355
+ const fromDir = this.sourceOutlineParam !== null ? getDirectionFromOutlineParam(this.sourceOutlineParam, this.sourceNode?.getBounds()) : this.sourceAnchorId?.split(":")[0];
2356
+ const toDir = this.previewTargetOutlineParam !== null ? getDirectionFromOutlineParam(this.previewTargetOutlineParam, previewTarget?.getBounds()) : this.previewTargetAnchorId?.split(":")[0];
2314
2357
  this.drawBezierPreview(ctx, start, end, fromDir, toDir);
2315
2358
  }
2316
2359
  ctx.setLineDash([]);
@@ -3777,7 +3820,781 @@ var StraightPathStrategy = class {
3777
3820
  }
3778
3821
  };
3779
3822
  //#endregion
3823
+ //#region src/elements/paths/routeOrthogonalAround.ts
3824
+ var DEFAULT_MARGIN = 12;
3825
+ var DEFAULT_EXIT = 20;
3826
+ var BEND_PENALTY = .001;
3827
+ /** Prefer mid-gap over hugging obstacle borders (same Manhattan, fewer bends). */
3828
+ var CONTOUR_BAND = 18;
3829
+ var CONTOUR_PENALTY = 140;
3830
+ function expandRect(r, m) {
3831
+ return {
3832
+ ...r,
3833
+ x: r.x - m,
3834
+ y: r.y - m,
3835
+ width: r.width + 2 * m,
3836
+ height: r.height + 2 * m
3837
+ };
3838
+ }
3839
+ /**
3840
+ * Diagram padding + router margin can seal a real visual gap between stacked nodes
3841
+ * (e.g. ArchiMate BP header above a Product ~12px apart, 8px pad each side).
3842
+ * Shrink facing sides of the expanded rects so a mid-gap corridor stays open.
3843
+ */
3844
+ function preserveFacingGap(expandedA, expandedB, rawA, rawB, minCorridor = 2) {
3845
+ let a = { ...expandedA };
3846
+ let b = { ...expandedB };
3847
+ if (rawB.y - (rawA.y + rawA.height) >= minCorridor) {
3848
+ const mid = (rawA.y + rawA.height + rawB.y) / 2;
3849
+ const half = minCorridor / 2;
3850
+ const aBottom = mid - half;
3851
+ const bTop = mid + half;
3852
+ if (a.y + a.height > aBottom) a = {
3853
+ ...a,
3854
+ height: Math.max(0, aBottom - a.y)
3855
+ };
3856
+ if (b.y < bTop) b = {
3857
+ ...b,
3858
+ height: Math.max(0, b.y + b.height - bTop),
3859
+ y: bTop
3860
+ };
3861
+ return [a, b];
3862
+ }
3863
+ if (rawA.y - (rawB.y + rawB.height) >= minCorridor) {
3864
+ const mid = (rawB.y + rawB.height + rawA.y) / 2;
3865
+ const half = minCorridor / 2;
3866
+ const bBottom = mid - half;
3867
+ const aTop = mid + half;
3868
+ if (b.y + b.height > bBottom) b = {
3869
+ ...b,
3870
+ height: Math.max(0, bBottom - b.y)
3871
+ };
3872
+ if (a.y < aTop) a = {
3873
+ ...a,
3874
+ height: Math.max(0, a.y + a.height - aTop),
3875
+ y: aTop
3876
+ };
3877
+ return [a, b];
3878
+ }
3879
+ if (rawB.x - (rawA.x + rawA.width) >= minCorridor) {
3880
+ const mid = (rawA.x + rawA.width + rawB.x) / 2;
3881
+ const half = minCorridor / 2;
3882
+ const aRight = mid - half;
3883
+ const bLeft = mid + half;
3884
+ if (a.x + a.width > aRight) a = {
3885
+ ...a,
3886
+ width: Math.max(0, aRight - a.x)
3887
+ };
3888
+ if (b.x < bLeft) b = {
3889
+ ...b,
3890
+ width: Math.max(0, b.x + b.width - bLeft),
3891
+ x: bLeft
3892
+ };
3893
+ return [a, b];
3894
+ }
3895
+ if (rawA.x - (rawB.x + rawB.width) >= minCorridor) {
3896
+ const mid = (rawB.x + rawB.width + rawA.x) / 2;
3897
+ const half = minCorridor / 2;
3898
+ const bRight = mid - half;
3899
+ const aLeft = mid + half;
3900
+ if (b.x + b.width > bRight) b = {
3901
+ ...b,
3902
+ width: Math.max(0, bRight - b.x)
3903
+ };
3904
+ if (a.x < aLeft) a = {
3905
+ ...a,
3906
+ width: Math.max(0, a.x + a.width - aLeft),
3907
+ x: aLeft
3908
+ };
3909
+ return [a, b];
3910
+ }
3911
+ return [a, b];
3912
+ }
3913
+ function insetRect(r, m) {
3914
+ if (m <= 0) return r;
3915
+ const width = r.width - 2 * m;
3916
+ const height = r.height - 2 * m;
3917
+ if (width <= 1 || height <= 1) return null;
3918
+ return {
3919
+ ...r,
3920
+ x: r.x + m,
3921
+ y: r.y + m,
3922
+ width,
3923
+ height
3924
+ };
3925
+ }
3926
+ /** Midline of a positive gap between two raw rects, if any. */
3927
+ function facingGapMid(rawA, rawB) {
3928
+ if (rawA.y + rawA.height < rawB.y) return {
3929
+ x: (rawA.x + rawA.width / 2 + rawB.x + rawB.width / 2) / 2,
3930
+ y: (rawA.y + rawA.height + rawB.y) / 2
3931
+ };
3932
+ if (rawB.y + rawB.height < rawA.y) return {
3933
+ x: (rawA.x + rawA.width / 2 + rawB.x + rawB.width / 2) / 2,
3934
+ y: (rawB.y + rawB.height + rawA.y) / 2
3935
+ };
3936
+ if (rawA.x + rawA.width < rawB.x) return {
3937
+ x: (rawA.x + rawA.width + rawB.x) / 2,
3938
+ y: (rawA.y + rawA.height / 2 + rawB.y + rawB.height / 2) / 2
3939
+ };
3940
+ if (rawB.x + rawB.width < rawA.x) return {
3941
+ x: (rawB.x + rawB.width + rawA.x) / 2,
3942
+ y: (rawA.y + rawA.height / 2 + rawB.y + rawB.height / 2) / 2
3943
+ };
3944
+ return null;
3945
+ }
3946
+ /**
3947
+ * Obstacles arrive pre-padded (diagram pad 4–8). Peel common pad amounts so a
3948
+ * real visual gap between stacked nodes is visible to corridor / endEntry logic.
3949
+ */
3950
+ function peelForFacingGap(a, b) {
3951
+ for (const peel of [
3952
+ 0,
3953
+ 4,
3954
+ 8
3955
+ ]) {
3956
+ const aa = insetRect(a, peel);
3957
+ const bb = insetRect(b, peel);
3958
+ if (!aa || !bb) continue;
3959
+ if (facingGapMid(aa, bb)) return {
3960
+ a: aa,
3961
+ b: bb
3962
+ };
3963
+ }
3964
+ return null;
3965
+ }
3966
+ function moveByDir$1(p, dir, d) {
3967
+ switch (dir) {
3968
+ case "top": return {
3969
+ x: p.x,
3970
+ y: p.y - d
3971
+ };
3972
+ case "bottom": return {
3973
+ x: p.x,
3974
+ y: p.y + d
3975
+ };
3976
+ case "left": return {
3977
+ x: p.x - d,
3978
+ y: p.y
3979
+ };
3980
+ case "right": return {
3981
+ x: p.x + d,
3982
+ y: p.y
3983
+ };
3984
+ }
3985
+ }
3986
+ function pointInOpenInterior(p, r) {
3987
+ return p.x > r.x && p.x < r.x + r.width && p.y > r.y && p.y < r.y + r.height;
3988
+ }
3989
+ /** Axis-aligned segment vs open interior of a rect (boundary grazing allowed). */
3990
+ function segmentIntersectsOpenInterior(a, b, r) {
3991
+ if (Math.abs(a.x - b.x) < .001) {
3992
+ const x = a.x;
3993
+ const y1 = Math.min(a.y, b.y);
3994
+ const y2 = Math.max(a.y, b.y);
3995
+ return x > r.x && x < r.x + r.width && y2 > r.y && y1 < r.y + r.height;
3996
+ }
3997
+ if (Math.abs(a.y - b.y) < .001) {
3998
+ const y = a.y;
3999
+ const x1 = Math.min(a.x, b.x);
4000
+ const x2 = Math.max(a.x, b.x);
4001
+ return y > r.y && y < r.y + r.height && x2 > r.x && x1 < r.x + r.width;
4002
+ }
4003
+ return true;
4004
+ }
4005
+ function segmentHitsObstacles(a, b, obstacles) {
4006
+ return obstacles.some((o) => segmentIntersectsOpenInterior(a, b, o));
4007
+ }
4008
+ function manhattan$1(a, b) {
4009
+ return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
4010
+ }
4011
+ /**
4012
+ * Extra cost when a segment runs parallel to an obstacle edge inside CONTOUR_BAND.
4013
+ * Scaled by segment length so a long “lick” along a wide BP costs far more than a short stub.
4014
+ */
4015
+ function contourProximityPenalty(a, b, obstacles) {
4016
+ if (obstacles.length === 0) return 0;
4017
+ const segLen = manhattan$1(a, b);
4018
+ if (segLen < 1) return 0;
4019
+ let minDist = Infinity;
4020
+ if (Math.abs(a.y - b.y) < .001) {
4021
+ const y = a.y;
4022
+ const x1 = Math.min(a.x, b.x);
4023
+ const x2 = Math.max(a.x, b.x);
4024
+ for (const o of obstacles) {
4025
+ if (x2 < o.x - 1 || x1 > o.x + o.width + 1) continue;
4026
+ minDist = Math.min(minDist, Math.abs(y - o.y), Math.abs(y - (o.y + o.height)));
4027
+ }
4028
+ } else if (Math.abs(a.x - b.x) < .001) {
4029
+ const x = a.x;
4030
+ const y1 = Math.min(a.y, b.y);
4031
+ const y2 = Math.max(a.y, b.y);
4032
+ for (const o of obstacles) {
4033
+ if (y2 < o.y - 1 || y1 > o.y + o.height + 1) continue;
4034
+ minDist = Math.min(minDist, Math.abs(x - o.x), Math.abs(x - (o.x + o.width)));
4035
+ }
4036
+ } else return 0;
4037
+ if (!Number.isFinite(minDist) || minDist >= CONTOUR_BAND) return 0;
4038
+ const proximity = 1 - minDist / CONTOUR_BAND;
4039
+ return segLen * (CONTOUR_PENALTY / 10) * proximity;
4040
+ }
4041
+ /** Push along dir until outside expanded parent (and at least exitDistance). */
4042
+ function computeStartExit(from, fromDir, parent, margin, exitDistance) {
4043
+ let p = moveByDir$1(from, fromDir, exitDistance);
4044
+ if (!parent) return p;
4045
+ const expanded = expandRect(parent, margin);
4046
+ let guard = 0;
4047
+ while (pointInOpenInterior(p, expanded) && guard++ < 64) p = moveByDir$1(p, fromDir, exitDistance);
4048
+ if (pointInOpenInterior(p, expanded)) {
4049
+ const e = expanded;
4050
+ switch (fromDir) {
4051
+ case "left":
4052
+ p = {
4053
+ x: e.x - 1,
4054
+ y: from.y
4055
+ };
4056
+ break;
4057
+ case "right":
4058
+ p = {
4059
+ x: e.x + e.width + 1,
4060
+ y: from.y
4061
+ };
4062
+ break;
4063
+ case "top":
4064
+ p = {
4065
+ x: from.x,
4066
+ y: e.y - 1
4067
+ };
4068
+ break;
4069
+ case "bottom":
4070
+ p = {
4071
+ x: from.x,
4072
+ y: e.y + e.height + 1
4073
+ };
4074
+ break;
4075
+ }
4076
+ }
4077
+ return p;
4078
+ }
4079
+ function computeEndEntry(to, toDir, exitDistance, rawTarget, rawNeighbor) {
4080
+ const stub = moveByDir$1(to, toDir, exitDistance);
4081
+ if (!rawTarget || !rawNeighbor) return stub;
4082
+ const mid = facingGapMid(rawTarget, rawNeighbor);
4083
+ if (!mid) return stub;
4084
+ switch (toDir) {
4085
+ case "bottom":
4086
+ if (mid.y > to.y && rawNeighbor.y >= rawTarget.y + rawTarget.height) return {
4087
+ x: to.x,
4088
+ y: mid.y
4089
+ };
4090
+ break;
4091
+ case "top":
4092
+ if (mid.y < to.y && rawNeighbor.y + rawNeighbor.height <= rawTarget.y) return {
4093
+ x: to.x,
4094
+ y: mid.y
4095
+ };
4096
+ break;
4097
+ case "right":
4098
+ if (mid.x > to.x && rawNeighbor.x >= rawTarget.x + rawTarget.width) return {
4099
+ x: mid.x,
4100
+ y: to.y
4101
+ };
4102
+ break;
4103
+ case "left":
4104
+ if (mid.x < to.x && rawNeighbor.x + rawNeighbor.width <= rawTarget.x) return {
4105
+ x: mid.x,
4106
+ y: to.y
4107
+ };
4108
+ break;
4109
+ }
4110
+ return stub;
4111
+ }
4112
+ /**
4113
+ * When nodes face each other closer than 2×exitDistance, fixed stubs cross
4114
+ * (startExit past endEntry). That draws a U-turn "tail" at the first bend.
4115
+ * Meet both stubs on the mid-gap instead.
4116
+ */
4117
+ function clampFacingExitEntry(from, to, fromDir, toDir, startExit, endEntry) {
4118
+ if (fromDir === "bottom" && toDir === "top" && to.y > from.y && startExit.y > endEntry.y) {
4119
+ const midY = (from.y + to.y) / 2;
4120
+ return {
4121
+ startExit: {
4122
+ x: startExit.x,
4123
+ y: midY
4124
+ },
4125
+ endEntry: {
4126
+ x: endEntry.x,
4127
+ y: midY
4128
+ }
4129
+ };
4130
+ }
4131
+ if (fromDir === "top" && toDir === "bottom" && to.y < from.y && startExit.y < endEntry.y) {
4132
+ const midY = (from.y + to.y) / 2;
4133
+ return {
4134
+ startExit: {
4135
+ x: startExit.x,
4136
+ y: midY
4137
+ },
4138
+ endEntry: {
4139
+ x: endEntry.x,
4140
+ y: midY
4141
+ }
4142
+ };
4143
+ }
4144
+ if (fromDir === "right" && toDir === "left" && to.x > from.x && startExit.x > endEntry.x) {
4145
+ const midX = (from.x + to.x) / 2;
4146
+ return {
4147
+ startExit: {
4148
+ x: midX,
4149
+ y: startExit.y
4150
+ },
4151
+ endEntry: {
4152
+ x: midX,
4153
+ y: endEntry.y
4154
+ }
4155
+ };
4156
+ }
4157
+ if (fromDir === "left" && toDir === "right" && to.x < from.x && startExit.x < endEntry.x) {
4158
+ const midX = (from.x + to.x) / 2;
4159
+ return {
4160
+ startExit: {
4161
+ x: midX,
4162
+ y: startExit.y
4163
+ },
4164
+ endEntry: {
4165
+ x: midX,
4166
+ y: endEntry.y
4167
+ }
4168
+ };
4169
+ }
4170
+ return {
4171
+ startExit,
4172
+ endEntry
4173
+ };
4174
+ }
4175
+ function simplifyPath$1(path) {
4176
+ const out = [];
4177
+ for (const p of path) {
4178
+ const prev = out[out.length - 1];
4179
+ if (!prev || Math.abs(prev.x - p.x) > .001 || Math.abs(prev.y - p.y) > .001) out.push(p);
4180
+ }
4181
+ let i = 0;
4182
+ while (i + 2 < out.length) {
4183
+ const a = out[i];
4184
+ const b = out[i + 1];
4185
+ const c = out[i + 2];
4186
+ const colinearV = Math.abs(a.x - b.x) < .001 && Math.abs(b.x - c.x) < .001;
4187
+ const colinearH = Math.abs(a.y - b.y) < .001 && Math.abs(b.y - c.y) < .001;
4188
+ const colinear = colinearV || colinearH;
4189
+ const uturn = colinearV && (a.y < c.y && c.y < b.y || b.y < c.y && c.y < a.y) || colinearH && (a.x < c.x && c.x < b.x || b.x < c.x && c.x < a.x);
4190
+ const spur = colinearV && (b.y < a.y && a.y < c.y || c.y < a.y && a.y < b.y) || colinearH && (b.x < a.x && a.x < c.x || c.x < a.x && a.x < b.x);
4191
+ if (uturn || spur || colinear && i > 0 && i + 2 < out.length - 1) out.splice(i + 1, 1);
4192
+ else i++;
4193
+ }
4194
+ return out;
4195
+ }
4196
+ function unionBBox(obstacles) {
4197
+ let x1 = Infinity;
4198
+ let y1 = Infinity;
4199
+ let x2 = -Infinity;
4200
+ let y2 = -Infinity;
4201
+ for (const o of obstacles) {
4202
+ x1 = Math.min(x1, o.x);
4203
+ y1 = Math.min(y1, o.y);
4204
+ x2 = Math.max(x2, o.x + o.width);
4205
+ y2 = Math.max(y2, o.y + o.height);
4206
+ }
4207
+ return {
4208
+ x: x1,
4209
+ y: y1,
4210
+ width: x2 - x1,
4211
+ height: y2 - y1
4212
+ };
4213
+ }
4214
+ function collectLatticeCoords(startExit, endEntry, obstacles, parent, target, margin) {
4215
+ const xSet = new Set([startExit.x, endEntry.x]);
4216
+ const ySet = new Set([startExit.y, endEntry.y]);
4217
+ for (const o of obstacles) {
4218
+ xSet.add(o.x);
4219
+ xSet.add(o.x + o.width);
4220
+ ySet.add(o.y);
4221
+ ySet.add(o.y + o.height);
4222
+ xSet.add(o.x - 1);
4223
+ xSet.add(o.x + o.width + 1);
4224
+ ySet.add(o.y - 1);
4225
+ ySet.add(o.y + o.height + 1);
4226
+ }
4227
+ if (parent && target) {
4228
+ const addGapY = (gapY) => {
4229
+ ySet.add(gapY);
4230
+ const left = Math.min(parent.x, target.x, startExit.x, endEntry.x);
4231
+ const right = Math.max(parent.x + parent.width, target.x + target.width, startExit.x, endEntry.x);
4232
+ xSet.add(left);
4233
+ xSet.add(right);
4234
+ xSet.add(startExit.x);
4235
+ xSet.add(endEntry.x);
4236
+ };
4237
+ const addGapX = (gapX) => {
4238
+ xSet.add(gapX);
4239
+ const top = Math.min(parent.y, target.y, startExit.y, endEntry.y);
4240
+ const bottom = Math.max(parent.y + parent.height, target.y + target.height, startExit.y, endEntry.y);
4241
+ ySet.add(top);
4242
+ ySet.add(bottom);
4243
+ ySet.add(startExit.y);
4244
+ ySet.add(endEntry.y);
4245
+ };
4246
+ const mid = facingGapMid(parent, target);
4247
+ if (mid) if (parent.y + parent.height < target.y || target.y + target.height < parent.y) addGapY(mid.y);
4248
+ else addGapX(mid.x);
4249
+ else if (margin != null) {
4250
+ const ep = expandRect(parent, margin);
4251
+ const et = expandRect(target, margin);
4252
+ if (et.y + et.height < ep.y) addGapY((et.y + et.height + ep.y) / 2);
4253
+ if (ep.y + ep.height < et.y) addGapY((ep.y + ep.height + et.y) / 2);
4254
+ if (et.x + et.width < ep.x) addGapX((et.x + et.width + ep.x) / 2);
4255
+ if (ep.x + ep.width < et.x) addGapX((ep.x + ep.width + et.x) / 2);
4256
+ }
4257
+ }
4258
+ return {
4259
+ xs: [...xSet].sort((a, b) => a - b),
4260
+ ys: [...ySet].sort((a, b) => a - b)
4261
+ };
4262
+ }
4263
+ function buildCornerGraph(startExit, endEntry, obstacles, parent, target, margin) {
4264
+ const { xs, ys } = collectLatticeCoords(startExit, endEntry, obstacles, parent, target, margin);
4265
+ const nodes = [];
4266
+ const nodeIndex = /* @__PURE__ */ new Map();
4267
+ const nodeKey = (x, y) => `${x}|${y}`;
4268
+ const addNode = (x, y) => {
4269
+ const key = nodeKey(x, y);
4270
+ if (nodeIndex.has(key)) return;
4271
+ const point = {
4272
+ x,
4273
+ y
4274
+ };
4275
+ if (obstacles.some((o) => pointInOpenInterior(point, o))) return;
4276
+ nodeIndex.set(key, nodes.length);
4277
+ nodes.push(point);
4278
+ };
4279
+ for (const x of xs) for (const y of ys) addNode(x, y);
4280
+ addNode(startExit.x, startExit.y);
4281
+ addNode(endEntry.x, endEntry.y);
4282
+ const startIdx = nodeIndex.get(nodeKey(startExit.x, startExit.y));
4283
+ const goalIdx = nodeIndex.get(nodeKey(endEntry.x, endEntry.y));
4284
+ if (startIdx == null || goalIdx == null) return null;
4285
+ const edges = /* @__PURE__ */ new Map();
4286
+ const pushEdge = (a, b, dir, length) => {
4287
+ const list = edges.get(a) ?? [];
4288
+ list.push({
4289
+ to: b,
4290
+ dir,
4291
+ length
4292
+ });
4293
+ edges.set(a, list);
4294
+ };
4295
+ const byX = /* @__PURE__ */ new Map();
4296
+ const byY = /* @__PURE__ */ new Map();
4297
+ for (let i = 0; i < nodes.length; i++) {
4298
+ const node = nodes[i];
4299
+ const xsList = byX.get(node.x) ?? [];
4300
+ xsList.push(i);
4301
+ byX.set(node.x, xsList);
4302
+ const ysList = byY.get(node.y) ?? [];
4303
+ ysList.push(i);
4304
+ byY.set(node.y, ysList);
4305
+ }
4306
+ for (const list of byX.values()) {
4307
+ list.sort((a, b) => nodes[a].y - nodes[b].y);
4308
+ for (let i = 1; i < list.length; i++) {
4309
+ const a = nodes[list[i - 1]];
4310
+ const b = nodes[list[i]];
4311
+ if (!segmentHitsObstacles(a, b, obstacles)) {
4312
+ const length = manhattan$1(a, b) + contourProximityPenalty(a, b, obstacles);
4313
+ pushEdge(list[i - 1], list[i], "v", length);
4314
+ pushEdge(list[i], list[i - 1], "v", length);
4315
+ }
4316
+ }
4317
+ }
4318
+ for (const list of byY.values()) {
4319
+ list.sort((a, b) => nodes[a].x - nodes[b].x);
4320
+ for (let i = 1; i < list.length; i++) {
4321
+ const a = nodes[list[i - 1]];
4322
+ const b = nodes[list[i]];
4323
+ if (!segmentHitsObstacles(a, b, obstacles)) {
4324
+ const length = manhattan$1(a, b) + contourProximityPenalty(a, b, obstacles);
4325
+ pushEdge(list[i - 1], list[i], "h", length);
4326
+ pushEdge(list[i], list[i - 1], "h", length);
4327
+ }
4328
+ }
4329
+ }
4330
+ return {
4331
+ nodes,
4332
+ edges,
4333
+ startIdx,
4334
+ goalIdx
4335
+ };
4336
+ }
4337
+ function searchMidPath(nodes, edges, startIdx, goalIdx) {
4338
+ const makeStateKey = (node, dir) => `${node}:${dir}`;
4339
+ const open = [];
4340
+ const best = /* @__PURE__ */ new Map();
4341
+ const prev = /* @__PURE__ */ new Map();
4342
+ const startKey = makeStateKey(startIdx, "s");
4343
+ best.set(startKey, 0);
4344
+ open.push({
4345
+ node: startIdx,
4346
+ dir: "s",
4347
+ g: 0,
4348
+ f: manhattan$1(nodes[startIdx], nodes[goalIdx]),
4349
+ key: startKey
4350
+ });
4351
+ let goalKey = null;
4352
+ while (open.length > 0) {
4353
+ let bestIdx = 0;
4354
+ for (let i = 1; i < open.length; i++) if (open[i].f < open[bestIdx].f) bestIdx = i;
4355
+ const current = open.splice(bestIdx, 1)[0];
4356
+ if (current.node === goalIdx) {
4357
+ goalKey = current.key;
4358
+ break;
4359
+ }
4360
+ for (const edge of edges.get(current.node) ?? []) {
4361
+ const nextDir = edge.dir;
4362
+ let stepCost = edge.length;
4363
+ if (current.dir !== "s" && current.dir !== nextDir) stepCost += BEND_PENALTY;
4364
+ const nextG = current.g + stepCost;
4365
+ const nextKey = makeStateKey(edge.to, nextDir);
4366
+ if (nextG >= (best.get(nextKey) ?? Infinity)) continue;
4367
+ best.set(nextKey, nextG);
4368
+ prev.set(nextKey, current.key);
4369
+ open.push({
4370
+ node: edge.to,
4371
+ dir: nextDir,
4372
+ g: nextG,
4373
+ f: nextG + manhattan$1(nodes[edge.to], nodes[goalIdx]),
4374
+ key: nextKey
4375
+ });
4376
+ }
4377
+ }
4378
+ if (!goalKey) return null;
4379
+ const path = [];
4380
+ let key = goalKey;
4381
+ while (key) {
4382
+ const nodeIdx = Number(key.split(":")[0]);
4383
+ path.unshift(nodes[nodeIdx]);
4384
+ key = prev.get(key);
4385
+ }
4386
+ return path;
4387
+ }
4388
+ function pathIsValid(path, obstacles) {
4389
+ for (let i = 1; i < path.length; i++) if (segmentHitsObstacles(path[i - 1], path[i], obstacles)) return false;
4390
+ return true;
4391
+ }
4392
+ /** Always orthogonal: never emit a diagonal between startExit and endEntry. */
4393
+ function orthogonalElbow(startExit, endEntry) {
4394
+ if (Math.abs(startExit.x - endEntry.x) < .001 || Math.abs(startExit.y - endEntry.y) < .001) return [startExit, endEntry];
4395
+ return [
4396
+ startExit,
4397
+ {
4398
+ x: startExit.x,
4399
+ y: endEntry.y
4400
+ },
4401
+ endEntry
4402
+ ];
4403
+ }
4404
+ function fallbackOuterRoute(startExit, endEntry, obstacles) {
4405
+ if (obstacles.length === 0) return orthogonalElbow(startExit, endEntry);
4406
+ const u = unionBBox(obstacles);
4407
+ const pad = 1;
4408
+ const top = u.y - pad;
4409
+ const bottom = u.y + u.height + pad;
4410
+ const left = u.x - pad;
4411
+ const right = u.x + u.width + pad;
4412
+ const candidates = [
4413
+ [
4414
+ startExit,
4415
+ {
4416
+ x: startExit.x,
4417
+ y: top
4418
+ },
4419
+ {
4420
+ x: endEntry.x,
4421
+ y: top
4422
+ },
4423
+ endEntry
4424
+ ],
4425
+ [
4426
+ startExit,
4427
+ {
4428
+ x: startExit.x,
4429
+ y: bottom
4430
+ },
4431
+ {
4432
+ x: endEntry.x,
4433
+ y: bottom
4434
+ },
4435
+ endEntry
4436
+ ],
4437
+ [
4438
+ startExit,
4439
+ {
4440
+ x: left,
4441
+ y: startExit.y
4442
+ },
4443
+ {
4444
+ x: left,
4445
+ y: endEntry.y
4446
+ },
4447
+ endEntry
4448
+ ],
4449
+ [
4450
+ startExit,
4451
+ {
4452
+ x: right,
4453
+ y: startExit.y
4454
+ },
4455
+ {
4456
+ x: right,
4457
+ y: endEntry.y
4458
+ },
4459
+ endEntry
4460
+ ],
4461
+ [
4462
+ startExit,
4463
+ {
4464
+ x: startExit.x,
4465
+ y: endEntry.y
4466
+ },
4467
+ endEntry
4468
+ ],
4469
+ [
4470
+ startExit,
4471
+ {
4472
+ x: endEntry.x,
4473
+ y: startExit.y
4474
+ },
4475
+ endEntry
4476
+ ]
4477
+ ];
4478
+ let best = null;
4479
+ let bestLen = Infinity;
4480
+ for (const candidate of candidates) {
4481
+ const simplified = simplifyPath$1(candidate);
4482
+ if (!pathIsValid(simplified, obstacles)) continue;
4483
+ const len = simplified.reduce((sum, p, i) => {
4484
+ if (i === 0) return 0;
4485
+ return sum + manhattan$1(simplified[i - 1], p);
4486
+ }, 0);
4487
+ if (len < bestLen) {
4488
+ bestLen = len;
4489
+ best = simplified;
4490
+ }
4491
+ }
4492
+ return best ?? orthogonalElbow(startExit, endEntry);
4493
+ }
4494
+ function routeMidPath(startExit, endEntry, obstacles, parent, target, margin) {
4495
+ if (Math.abs(startExit.x - endEntry.x) < .001 || Math.abs(startExit.y - endEntry.y) < .001) {
4496
+ const direct = [startExit, endEntry];
4497
+ if (pathIsValid(direct, obstacles)) return direct;
4498
+ }
4499
+ const graph = buildCornerGraph(startExit, endEntry, obstacles, parent, target, margin);
4500
+ if (graph) {
4501
+ const mid = searchMidPath(graph.nodes, graph.edges, graph.startIdx, graph.goalIdx);
4502
+ if (mid && pathIsValid(mid, obstacles)) return mid;
4503
+ }
4504
+ return fallbackOuterRoute(startExit, endEntry, obstacles);
4505
+ }
4506
+ function routeOrthogonalAround(input) {
4507
+ const margin = input.margin ?? DEFAULT_MARGIN;
4508
+ const exitDistance = input.exitDistance ?? DEFAULT_EXIT;
4509
+ const exitBlocker = input.parent ?? input.source;
4510
+ const rawStartExit = computeStartExit(input.from, input.fromDir, exitBlocker, margin, exitDistance);
4511
+ const gapNeighbor = input.parent ?? input.source;
4512
+ const peeledTargetNeighbor = input.target && gapNeighbor ? peelForFacingGap(input.target, gapNeighbor) : null;
4513
+ const rawEndEntry = computeEndEntry(input.to, input.toDir, exitDistance, peeledTargetNeighbor?.a, peeledTargetNeighbor?.b);
4514
+ const { startExit, endEntry } = clampFacingExitEntry(input.from, input.to, input.fromDir, input.toDir, rawStartExit, rawEndEntry);
4515
+ let sourceObs = input.source ? expandRect(input.source, margin) : void 0;
4516
+ let parentObs = input.parent ? expandRect(input.parent, margin) : void 0;
4517
+ let targetObs = input.target ? expandRect(input.target, margin) : void 0;
4518
+ const openCorridor = (expA, expB, inputA, inputB) => {
4519
+ const peeled = peelForFacingGap(inputA, inputB);
4520
+ if (peeled) return preserveFacingGap(expA, expB, peeled.a, peeled.b);
4521
+ return preserveFacingGap(expA, expB, inputA, inputB);
4522
+ };
4523
+ if (parentObs && targetObs && input.parent && input.target) [parentObs, targetObs] = openCorridor(parentObs, targetObs, input.parent, input.target);
4524
+ else if (sourceObs && targetObs && input.source && input.target) [sourceObs, targetObs] = openCorridor(sourceObs, targetObs, input.source, input.target);
4525
+ if (sourceObs && parentObs && input.source && input.parent) [sourceObs, parentObs] = openCorridor(sourceObs, parentObs, input.source, input.parent);
4526
+ const obstacles = [];
4527
+ if (sourceObs) obstacles.push(sourceObs);
4528
+ if (parentObs) obstacles.push(parentObs);
4529
+ if (targetObs) obstacles.push(targetObs);
4530
+ const peeledLattice = input.parent && input.target ? peelForFacingGap(input.parent, input.target) : input.source && input.target ? peelForFacingGap(input.source, input.target) : null;
4531
+ const mid = routeMidPath(startExit, endEntry, obstacles, peeledLattice?.a ?? input.parent ?? input.source, peeledLattice?.b ?? input.target, margin).slice(1, -1);
4532
+ const lastMid = mid[mid.length - 1];
4533
+ const alreadyApproaching = !!lastMid && isOnApproachOutside(lastMid, input.to, input.toDir, 1);
4534
+ return simplifyPath$1(ensureOrthogonalPath([
4535
+ input.from,
4536
+ startExit,
4537
+ ...mid,
4538
+ ...alreadyApproaching ? [] : [endEntry],
4539
+ input.to
4540
+ ]));
4541
+ }
4542
+ /** Insert elbows so no consecutive points form a diagonal (safety net). */
4543
+ function ensureOrthogonalPath(path) {
4544
+ if (path.length < 2) return path;
4545
+ const out = [path[0]];
4546
+ for (let i = 1; i < path.length; i++) {
4547
+ const prev = out[out.length - 1];
4548
+ const next = path[i];
4549
+ if (!(Math.abs(prev.x - next.x) < .001 || Math.abs(prev.y - next.y) < .001)) out.push({
4550
+ x: prev.x,
4551
+ y: next.y
4552
+ });
4553
+ out.push(next);
4554
+ }
4555
+ return out;
4556
+ }
4557
+ function isOnApproachOutside(p, to, toDir, minClearance) {
4558
+ switch (toDir) {
4559
+ case "top": return Math.abs(p.x - to.x) < .001 && p.y <= to.y - minClearance;
4560
+ case "bottom": return Math.abs(p.x - to.x) < .001 && p.y >= to.y + minClearance;
4561
+ case "left": return Math.abs(p.y - to.y) < .001 && p.x <= to.x - minClearance;
4562
+ case "right": return Math.abs(p.y - to.y) < .001 && p.x >= to.x + minClearance;
4563
+ }
4564
+ }
4565
+ //#endregion
4566
+ //#region src/utils/routeDebug.ts
4567
+ function isRouteDebugEnabled() {
4568
+ if (typeof globalThis === "undefined") return false;
4569
+ const g = globalThis;
4570
+ if (g.__PAPIRUS_ROUTE_DEBUG__ === true || g.__PAPIRUS_ROUTE_DEBUG__ === 1) return true;
4571
+ try {
4572
+ if (typeof localStorage !== "undefined" && localStorage.getItem("papirusRouteDebug") === "1") return true;
4573
+ } catch {}
4574
+ return false;
4575
+ }
4576
+ function routeDebug(tag, payload) {
4577
+ if (!isRouteDebugEnabled()) return;
4578
+ console.log(`[papirus:route] ${tag}`, payload);
4579
+ }
4580
+ //#endregion
3780
4581
  //#region src/elements/paths/PolylinePathStrategy.ts
4582
+ function isSide(v) {
4583
+ return v === "top" || v === "right" || v === "bottom" || v === "left";
4584
+ }
4585
+ function resolveRoutingRoles(from, to, obstacles) {
4586
+ const target = obstacles.find((o) => o.role === "target") ?? obstacles.find((o) => {
4587
+ const onVertical = (Math.abs(to.x - o.x) < 1 || Math.abs(to.x - (o.x + o.width)) < 1) && to.y >= o.y - 1 && to.y <= o.y + o.height + 1;
4588
+ const onHorizontal = (Math.abs(to.y - o.y) < 1 || Math.abs(to.y - (o.y + o.height)) < 1) && to.x >= o.x - 1 && to.x <= o.x + o.width + 1;
4589
+ return onVertical || onHorizontal;
4590
+ });
4591
+ const source = obstacles.find((o) => o.role === "source" && o !== target);
4592
+ return {
4593
+ parent: obstacles.find((o) => o !== target && o !== source && o.role !== "source" && from.x >= o.x && from.x <= o.x + o.width && from.y >= o.y && from.y <= o.y + o.height),
4594
+ source,
4595
+ target
4596
+ };
4597
+ }
3781
4598
  var MIN_SEGMENT_LENGTH = 20;
3782
4599
  var SELF_LOOP_MIN_DISTANCE$1 = 1;
3783
4600
  var SELF_LOOP_OFFSET$1 = 42;
@@ -3855,27 +4672,66 @@ function moveByDir(point, dir, distance) {
3855
4672
  default: return point;
3856
4673
  }
3857
4674
  }
3858
- function inferDir(from, to) {
4675
+ /** Push `point` along `dir` until it clears every obstacle (plus margin). */
4676
+ function moveOutsideObstacles(point, dir, obstacles, minDistance) {
4677
+ if (!dir || obstacles.length === 0) return moveByDir(point, dir, minDistance);
4678
+ const expanded = expandObstacles(obstacles, OBSTACLE_MARGIN);
4679
+ let dist = minDistance;
4680
+ let candidate = moveByDir(point, dir, dist);
4681
+ const step = 16;
4682
+ const maxDist = 4e3;
4683
+ while (dist < maxDist && expanded.some((obstacle) => pointInsideObstacle(candidate, obstacle))) {
4684
+ dist += step;
4685
+ candidate = moveByDir(point, dir, dist);
4686
+ }
4687
+ return candidate;
4688
+ }
4689
+ function inferExitDir(from, to) {
3859
4690
  const dx = to.x - from.x;
3860
4691
  const dy = to.y - from.y;
3861
4692
  if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? "right" : "left";
3862
4693
  return dy >= 0 ? "bottom" : "top";
3863
4694
  }
3864
- function oppositeDir(dir) {
3865
- switch (dir) {
3866
- case "left": return "right";
3867
- case "right": return "left";
3868
- case "top": return "bottom";
3869
- case "bottom": return "top";
3870
- default: return dir;
4695
+ /** Outward side at `to` we should approach from (not opposite of fromDir). */
4696
+ function inferApproachDir(from, to) {
4697
+ const dx = from.x - to.x;
4698
+ const dy = from.y - to.y;
4699
+ if (Math.abs(dy) > 1) return dy >= 0 ? "bottom" : "top";
4700
+ if (Math.abs(dx) > 1) return dx >= 0 ? "right" : "left";
4701
+ return dy >= 0 ? "bottom" : "top";
4702
+ }
4703
+ /** Which side of `target` the attachment point `to` lies on (border hit). */
4704
+ function sideOfPointOnRect(to, target) {
4705
+ const onLeft = Math.abs(to.x - target.x) <= 1;
4706
+ const onRight = Math.abs(to.x - (target.x + target.width)) <= 1;
4707
+ const onTop = Math.abs(to.y - target.y) <= 1;
4708
+ const onBottom = Math.abs(to.y - (target.y + target.height)) <= 1;
4709
+ if (onTop && !onLeft && !onRight) return "top";
4710
+ if (onBottom && !onLeft && !onRight) return "bottom";
4711
+ if (onLeft && !onTop && !onBottom) return "left";
4712
+ if (onRight && !onTop && !onBottom) return "right";
4713
+ }
4714
+ /**
4715
+ * Resolve approach side at `to`.
4716
+ * Prefer the side of the target rect where `to` actually sits — never override a
4717
+ * left/right attachment with top/bottom from source geometry (that puts endEntry
4718
+ * inside the target and draws the last bend through the node).
4719
+ */
4720
+ function resolveApproachDir(from, to, toDir, target) {
4721
+ if (target) {
4722
+ const side = sideOfPointOnRect(to, target);
4723
+ if (side) return side;
3871
4724
  }
4725
+ if (toDir && isSide(toDir)) return toDir;
4726
+ return inferApproachDir(from, to);
3872
4727
  }
3873
4728
  function buildRoutedPolyline(from, to, fromDir, toDir, obstacles) {
3874
4729
  if (obstacles.length === 0) return null;
3875
- const effectiveFromDir = fromDir ?? inferDir(from, to);
3876
- const effectiveToDir = toDir ?? oppositeDir(effectiveFromDir);
3877
- const startExit = moveByDir(from, effectiveFromDir, ROUTE_EXIT_DISTANCE);
3878
- const endEntry = moveByDir(to, effectiveToDir, ROUTE_EXIT_DISTANCE);
4730
+ const effectiveFromDir = fromDir ?? inferExitDir(from, to);
4731
+ const { target: routedTarget } = resolveRoutingRoles(from, to, obstacles);
4732
+ const effectiveToDir = resolveApproachDir(from, to, toDir, routedTarget);
4733
+ const startExit = moveOutsideObstacles(from, effectiveFromDir, obstacles, ROUTE_EXIT_DISTANCE);
4734
+ const endEntry = moveOutsideObstacles(to, effectiveToDir, obstacles, ROUTE_EXIT_DISTANCE);
3879
4735
  const expanded = expandObstacles(obstacles, OBSTACLE_MARGIN);
3880
4736
  const xs = new Set([startExit.x, endEntry.x]);
3881
4737
  const ys = new Set([startExit.y, endEntry.y]);
@@ -4020,12 +4876,11 @@ var PolylinePathStrategy = class {
4020
4876
  calculatePath(from, to, fromDir, toDir, _options) {
4021
4877
  const controlPoints = _options?.controlPoints;
4022
4878
  if (_options?.editablePolyline) {
4023
- if (controlPoints && controlPoints.length > 0) return [
4879
+ const path = controlPoints && controlPoints.length > 0 ? [
4024
4880
  from,
4025
4881
  ...controlPoints,
4026
4882
  to
4027
- ];
4028
- return [
4883
+ ] : [
4029
4884
  from,
4030
4885
  {
4031
4886
  x: (from.x + to.x) / 2,
@@ -4033,14 +4888,32 @@ var PolylinePathStrategy = class {
4033
4888
  },
4034
4889
  to
4035
4890
  ];
4891
+ routeDebug("polyline.via", {
4892
+ via: "editable",
4893
+ path
4894
+ });
4895
+ return path;
4036
4896
  }
4037
4897
  const dist = distance(from, to);
4038
4898
  const selfLoop = _options?.selfLoop ?? false;
4039
4899
  const obstacles = _options?.obstacles ?? [];
4040
- if (!selfLoop && dist < OBSTACLE_ROUTING_DISTANCE) {
4041
- const routed = buildRoutedPolyline(from, to, fromDir, toDir, obstacles);
4042
- if (routed) return routed;
4043
- }
4900
+ const finish = (via, path) => {
4901
+ const roles = resolveRoutingRoles(from, to, obstacles);
4902
+ routeDebug("polyline.via", {
4903
+ via,
4904
+ fromDir,
4905
+ toDir,
4906
+ approachDir: roles.target ? resolveApproachDir(from, to, toDir, roles.target) : toDir,
4907
+ roles: {
4908
+ parent: roles.parent?.id,
4909
+ source: roles.source?.id,
4910
+ target: roles.target?.id
4911
+ },
4912
+ obstacleRoles: obstacles.map((o) => `${o.id}:${o.role ?? "?"}`),
4913
+ path: path.map((p) => `${Math.round(p.x)},${Math.round(p.y)}`).join(" → ")
4914
+ });
4915
+ return path;
4916
+ };
4044
4917
  if (selfLoop && dist < SELF_LOOP_MIN_DISTANCE$1 && (fromDir || toDir)) switch (fromDir ?? toDir) {
4045
4918
  case "bottom": return [
4046
4919
  from,
@@ -4168,9 +5041,40 @@ var PolylinePathStrategy = class {
4168
5041
  to
4169
5042
  ];
4170
5043
  }
4171
- if (fromDir || toDir) return this.calculateDirectedPath(from, to, fromDir, toDir);
5044
+ if (!selfLoop && isSide(fromDir) && isSide(toDir)) {
5045
+ const { parent, source, target } = resolveRoutingRoles(from, to, obstacles);
5046
+ const approachDir = resolveApproachDir(from, to, toDir, target);
5047
+ if ((parent || source || target) && isSide(approachDir)) return finish("routeOrthogonalAround", routeOrthogonalAround({
5048
+ from,
5049
+ to,
5050
+ fromDir,
5051
+ toDir: approachDir,
5052
+ parent,
5053
+ source,
5054
+ target,
5055
+ margin: 4,
5056
+ exitDistance: 20
5057
+ }));
5058
+ routeDebug("polyline.via", {
5059
+ via: "roles-missed-around",
5060
+ fromDir,
5061
+ toDir,
5062
+ approachDir,
5063
+ roles: {
5064
+ parent: parent?.id,
5065
+ source: source?.id,
5066
+ target: target?.id
5067
+ },
5068
+ obstacleRoles: obstacles.map((o) => `${o.id}:${o.role ?? "?"}`)
5069
+ });
5070
+ }
5071
+ if (!selfLoop && dist < OBSTACLE_ROUTING_DISTANCE) {
5072
+ const routed = buildRoutedPolyline(from, to, fromDir, toDir, obstacles);
5073
+ if (routed) return finish("buildRoutedPolyline", routed);
5074
+ }
5075
+ if (fromDir || toDir) return finish("calculateDirectedPath", this.calculateDirectedPath(from, to, fromDir, toDir));
4172
5076
  const midX = (from.x + to.x) / 2;
4173
- if (Math.abs(to.x - from.x) > Math.abs(to.y - from.y)) return [
5077
+ if (Math.abs(to.x - from.x) > Math.abs(to.y - from.y)) return finish("manhattan-h", [
4174
5078
  from,
4175
5079
  {
4176
5080
  x: midX,
@@ -4181,9 +5085,9 @@ var PolylinePathStrategy = class {
4181
5085
  y: to.y
4182
5086
  },
4183
5087
  to
4184
- ];
5088
+ ]);
4185
5089
  const midY = (from.y + to.y) / 2;
4186
- return [
5090
+ return finish("manhattan-v", [
4187
5091
  from,
4188
5092
  {
4189
5093
  x: from.x,
@@ -4194,7 +5098,7 @@ var PolylinePathStrategy = class {
4194
5098
  y: midY
4195
5099
  },
4196
5100
  to
4197
- ];
5101
+ ]);
4198
5102
  }
4199
5103
  calculateDirectedPath(from, to, fromDir, toDir) {
4200
5104
  const points = [from];
@@ -4248,15 +5152,39 @@ var PolylinePathStrategy = class {
4248
5152
  y: to.y
4249
5153
  });
4250
5154
  }
4251
- } else if (fromVertical && toHorizontal) points.push({
4252
- x: from.x,
4253
- y: to.y
4254
- });
4255
- else if (fromHorizontal && toVertical) points.push({
4256
- x: to.x,
4257
- y: from.y
4258
- });
4259
- else {
5155
+ } else if (fromVertical && toHorizontal) {
5156
+ const exitY = fromDir === "top" ? from.y - MIN_SEGMENT_LENGTH : from.y + MIN_SEGMENT_LENGTH;
5157
+ const entryX = toDir === "left" ? to.x - MIN_SEGMENT_LENGTH : to.x + MIN_SEGMENT_LENGTH;
5158
+ const jogX = (from.x + to.x) / 2;
5159
+ const approachX = Math.abs(jogX - to.x) >= MIN_SEGMENT_LENGTH ? jogX : entryX;
5160
+ points.push({
5161
+ x: from.x,
5162
+ y: exitY
5163
+ });
5164
+ points.push({
5165
+ x: approachX,
5166
+ y: exitY
5167
+ });
5168
+ points.push({
5169
+ x: approachX,
5170
+ y: to.y
5171
+ });
5172
+ } else if (fromHorizontal && toVertical) {
5173
+ const exitX = fromDir === "left" ? from.x - MIN_SEGMENT_LENGTH : from.x + MIN_SEGMENT_LENGTH;
5174
+ const jogY = (from.y + to.y) / 2;
5175
+ points.push({
5176
+ x: exitX,
5177
+ y: from.y
5178
+ });
5179
+ points.push({
5180
+ x: exitX,
5181
+ y: jogY
5182
+ });
5183
+ points.push({
5184
+ x: to.x,
5185
+ y: jogY
5186
+ });
5187
+ } else {
4260
5188
  const midX = (from.x + to.x) / 2;
4261
5189
  points.push({
4262
5190
  x: midX,
@@ -4872,12 +5800,34 @@ function getEdgeLabelRotation(options) {
4872
5800
  return angle;
4873
5801
  }
4874
5802
  //#endregion
5803
+ //#region src/types.ts
5804
+ function isNodeEdgeEndpoint(endpoint) {
5805
+ return typeof endpoint.nodeId === "string" && endpoint.nodeId.length > 0;
5806
+ }
5807
+ function isEdgeEdgeEndpoint(endpoint) {
5808
+ return typeof endpoint.edgeId === "string" && endpoint.edgeId.length > 0;
5809
+ }
5810
+ //#endregion
4875
5811
  //#region src/elements/Edge.ts
4876
5812
  var DEFAULT_EDGE_STYLE = {
4877
5813
  strokeColor: "#666666",
4878
5814
  strokeWidth: 2,
4879
5815
  opacity: 1
4880
5816
  };
5817
+ /** Compare path options that affect polyline routing (not control-point identity). */
5818
+ function routingOptionsEqual(a, b) {
5819
+ const aObs = a?.obstacles;
5820
+ const bObs = b?.obstacles;
5821
+ if (aObs === bObs) return true;
5822
+ if (!aObs || !bObs) return !aObs && !bObs;
5823
+ if (aObs.length !== bObs.length) return false;
5824
+ for (let i = 0; i < aObs.length; i++) {
5825
+ const left = aObs[i];
5826
+ const right = bObs[i];
5827
+ if (left.id !== right.id || left.role !== right.role || left.x !== right.x || left.y !== right.y || left.width !== right.width || left.height !== right.height) return false;
5828
+ }
5829
+ return true;
5830
+ }
4881
5831
  /**
4882
5832
  * Connection edge between nodes
4883
5833
  */
@@ -5223,8 +6173,15 @@ var Edge = class extends Element {
5223
6173
  * @param toDir Direction at end point (top, right, bottom, left)
5224
6174
  */
5225
6175
  updateEndpoints(fromPoint, toPoint, fromDir, toDir, options) {
5226
- if (this._path.length > 0 && this._fromPoint.x === fromPoint.x && this._fromPoint.y === fromPoint.y && this._toPoint.x === toPoint.x && this._toPoint.y === toPoint.y && this._fromDir === fromDir && this._toDir === toDir) {
6176
+ const geometryUnchanged = this._path.length > 0 && this._fromPoint.x === fromPoint.x && this._fromPoint.y === fromPoint.y && this._toPoint.x === toPoint.x && this._toPoint.y === toPoint.y && this._fromDir === fromDir && this._toDir === toDir;
6177
+ const routingOptionsChanged = !routingOptionsEqual(this._pathOptions, options);
6178
+ if (geometryUnchanged && !routingOptionsChanged) {
5227
6179
  this._pathOptions = options;
6180
+ routeDebug("edge.updateEndpoints:skip", {
6181
+ id: this.id,
6182
+ type: this._type,
6183
+ reason: "geometry+routing unchanged"
6184
+ });
5228
6185
  return;
5229
6186
  }
5230
6187
  this._fromPoint = fromPoint;
@@ -5244,6 +6201,51 @@ var Edge = class extends Element {
5244
6201
  editablePolyline: this.isEditablePolyline(),
5245
6202
  selfLoop: this._from.nodeId === this._to.nodeId
5246
6203
  });
6204
+ if (this._type === "polyline" || this._type === "editable-polyline" || this._type === "bezier") {
6205
+ const obstacles = this._pathOptions?.obstacles ?? [];
6206
+ const segs = [];
6207
+ for (let i = 1; i < this._path.length; i++) {
6208
+ const a = this._path[i - 1];
6209
+ const b = this._path[i];
6210
+ const dx = b.x - a.x;
6211
+ const dy = b.y - a.y;
6212
+ const axis = Math.abs(dy) < .01 ? "H" : Math.abs(dx) < .01 ? "V" : `DIAG(${dx.toFixed(0)},${dy.toFixed(0)})`;
6213
+ segs.push({
6214
+ axis,
6215
+ len: Math.round(Math.abs(dx) + Math.abs(dy)),
6216
+ a: `${Math.round(a.x)},${Math.round(a.y)}`,
6217
+ b: `${Math.round(b.x)},${Math.round(b.y)}`
6218
+ });
6219
+ }
6220
+ routeDebug("edge.recalculatePath", {
6221
+ id: this.id,
6222
+ type: this._type,
6223
+ from: {
6224
+ nodeId: this._from.nodeId,
6225
+ portId: isNodeEdgeEndpoint(this._from) ? this._from.portId : void 0,
6226
+ outlineParam: isNodeEdgeEndpoint(this._from) ? this._from.outlineParam : void 0,
6227
+ point: `${Math.round(this._fromPoint.x)},${Math.round(this._fromPoint.y)}`,
6228
+ dir: this._fromDir
6229
+ },
6230
+ to: {
6231
+ nodeId: this._to.nodeId,
6232
+ portId: isNodeEdgeEndpoint(this._to) ? this._to.portId : void 0,
6233
+ outlineParam: isNodeEdgeEndpoint(this._to) ? this._to.outlineParam : void 0,
6234
+ point: `${Math.round(this._toPoint.x)},${Math.round(this._toPoint.y)}`,
6235
+ dir: this._toDir
6236
+ },
6237
+ lockAnchors: this.lockAnchors,
6238
+ obstacleCount: obstacles.length,
6239
+ obstacles: obstacles.map((o) => ({
6240
+ id: o.id,
6241
+ role: o.role,
6242
+ box: `${Math.round(o.x)},${Math.round(o.y)} ${Math.round(o.width)}x${Math.round(o.height)}`
6243
+ })),
6244
+ path: this._path.map((p) => `${Math.round(p.x)},${Math.round(p.y)}`).join(" → "),
6245
+ segments: segs,
6246
+ controlPoints: this._controlPoints?.length ?? 0
6247
+ });
6248
+ }
5247
6249
  this.updateBounds();
5248
6250
  this.markDirty();
5249
6251
  }
@@ -6971,14 +7973,6 @@ var ContextMenuManager = class extends EventEmitter {
6971
7973
  }
6972
7974
  };
6973
7975
  //#endregion
6974
- //#region src/types.ts
6975
- function isNodeEdgeEndpoint(endpoint) {
6976
- return typeof endpoint.nodeId === "string" && endpoint.nodeId.length > 0;
6977
- }
6978
- function isEdgeEdgeEndpoint(endpoint) {
6979
- return typeof endpoint.edgeId === "string" && endpoint.edgeId.length > 0;
6980
- }
6981
- //#endregion
6982
7976
  //#region src/core/EdgeEndpointUpdater.ts
6983
7977
  var EDGE_ATTACH_MAX_PASSES = 3;
6984
7978
  var EdgeEndpointUpdater = class {
@@ -6986,7 +7980,7 @@ var EdgeEndpointUpdater = class {
6986
7980
  this.host = host;
6987
7981
  }
6988
7982
  updateForDrag() {
6989
- this.update(false);
7983
+ this.update(true);
6990
7984
  }
6991
7985
  updateAll() {
6992
7986
  this.update(true);
@@ -7014,10 +8008,47 @@ var EdgeEndpointUpdater = class {
7014
8008
  const fromNode = nodes.get(edge.from.nodeId);
7015
8009
  const toNode = nodes.get(edge.to.nodeId);
7016
8010
  if (!fromNode || !toNode) return;
7017
- if (includeRoutingObstacles) this.lockAnchors(edge, fromNode, toNode);
8011
+ if (includeRoutingObstacles) {
8012
+ this.preferFacingSidePorts(edge, fromNode, toNode);
8013
+ this.lockAnchors(edge, fromNode, toNode);
8014
+ }
7018
8015
  const from = this.resolveNodeEndpoint(fromNode, edge.from, toNode.getCenter(), edge.lockAnchors);
7019
8016
  const to = this.resolveNodeEndpoint(toNode, edge.to, fromNode.getCenter(), edge.lockAnchors);
7020
- edge.updateEndpoints(from.point, to.point, from.direction, to.direction, obstacles ? { obstacles } : void 0);
8017
+ const fromId = edge.from.nodeId;
8018
+ const toId = edge.to.nodeId;
8019
+ const routingObstacles = obstacles?.map((obstacle) => {
8020
+ if (obstacle.id === fromId) return {
8021
+ ...obstacle,
8022
+ role: "source"
8023
+ };
8024
+ if (obstacle.id === toId) return {
8025
+ ...obstacle,
8026
+ role: "target"
8027
+ };
8028
+ return obstacle;
8029
+ });
8030
+ routeDebug("updater.nodeNode", {
8031
+ edgeId: edge.id,
8032
+ type: edge.type,
8033
+ includeRoutingObstacles,
8034
+ lockAnchors: edge.lockAnchors,
8035
+ from: {
8036
+ nodeId: fromId,
8037
+ portId: edge.from.portId,
8038
+ outlineParam: edge.from.outlineParam,
8039
+ point: `${Math.round(from.point.x)},${Math.round(from.point.y)}`,
8040
+ dir: from.direction
8041
+ },
8042
+ to: {
8043
+ nodeId: toId,
8044
+ portId: edge.to.portId,
8045
+ outlineParam: edge.to.outlineParam,
8046
+ point: `${Math.round(to.point.x)},${Math.round(to.point.y)}`,
8047
+ dir: to.direction
8048
+ },
8049
+ obstacleCount: routingObstacles?.length ?? 0
8050
+ });
8051
+ edge.updateEndpoints(from.point, to.point, from.direction, to.direction, routingObstacles ? { obstacles: routingObstacles } : void 0);
7021
8052
  }
7022
8053
  /**
7023
8054
  * @returns true when the resolved geometry changed enough to warrant another pass
@@ -7067,6 +8098,79 @@ var EdgeEndpointUpdater = class {
7067
8098
  }
7068
8099
  }
7069
8100
  /**
8101
+ * When nodes are clearly stacked, retarget side ports that would force the
8102
+ * polyline to approach along the long edge (contour crawl).
8103
+ *
8104
+ * Uses bounding-box gaps (not center dx/dy): a large horizontal offset must not
8105
+ * flip bottom→left while the source is still below the target — that reintroduces
8106
+ * the left-edge contour jog when the user drags the node leftward.
8107
+ *
8108
+ * - Always set facing ports when unset (so lockAnchors does not pick left/right).
8109
+ * - Rewrite target left/right → top/bottom when there is a clear vertical gap.
8110
+ * - Do not rewrite an intentional source side exit (e.g. left exit from a nested
8111
+ * component around its parent).
8112
+ */
8113
+ preferFacingSidePorts(edge, fromNode, toNode) {
8114
+ if (!edge.lockAnchors) return;
8115
+ if (!isNodeEdgeEndpoint(edge.from) || !isNodeEdgeEndpoint(edge.to)) return;
8116
+ if (edge.from.outlineParam !== void 0 || edge.to.outlineParam !== void 0) return;
8117
+ const fromB = fromNode.getBounds();
8118
+ const toB = toNode.getBounds();
8119
+ const fromBelow = fromB.y - (toB.y + toB.height);
8120
+ const fromAbove = toB.y - (fromB.y + fromB.height);
8121
+ const fromRight = fromB.x - (toB.x + toB.width);
8122
+ const fromLeft = toB.x - (fromB.x + fromB.width);
8123
+ const fc = fromNode.getCenter();
8124
+ const tc = toNode.getCenter();
8125
+ if (fromBelow > 0 || fromAbove > 0) {
8126
+ const fromSide = fromBelow > 0 ? "top" : "bottom";
8127
+ const toSide = fromBelow > 0 ? "bottom" : "top";
8128
+ const curFrom = this.portSide(edge.from.portId);
8129
+ const curTo = this.portSide(edge.to.portId);
8130
+ if (!curFrom) this.setEndpointSidePort(edge, "from", fromNode, fromSide, tc);
8131
+ if (!curTo || curTo === "left" || curTo === "right" || curTo === toSide) this.setEndpointSidePort(edge, "to", toNode, toSide, fc);
8132
+ return;
8133
+ }
8134
+ if (fromLeft > 0 || fromRight > 0) {
8135
+ const fromSide = fromRight > 0 ? "left" : "right";
8136
+ const toSide = fromRight > 0 ? "right" : "left";
8137
+ const curFrom = this.portSide(edge.from.portId);
8138
+ const curTo = this.portSide(edge.to.portId);
8139
+ if (!curFrom) this.setEndpointSidePort(edge, "from", fromNode, fromSide, tc);
8140
+ if (!curTo || curTo === "top" || curTo === "bottom") this.setEndpointSidePort(edge, "to", toNode, toSide, fc);
8141
+ }
8142
+ }
8143
+ portSide(portId) {
8144
+ if (!portId?.startsWith("anchor:")) return void 0;
8145
+ const side = portId.slice(ANCHOR_PORT_PREFIX.length).split(":")[0];
8146
+ if (side === "top" || side === "right" || side === "bottom" || side === "left") return side;
8147
+ }
8148
+ nearestAnchorOnSide(node, side, toward) {
8149
+ const anchors = node.getAnchors().filter((a) => a.id.startsWith(`${side}:`));
8150
+ if (anchors.length === 0) return null;
8151
+ let best = anchors[0];
8152
+ let minDist = Infinity;
8153
+ for (const anchor of anchors) {
8154
+ const dist = (anchor.point.x - toward.x) ** 2 + (anchor.point.y - toward.y) ** 2;
8155
+ if (dist < minDist) {
8156
+ minDist = dist;
8157
+ best = anchor;
8158
+ }
8159
+ }
8160
+ return best;
8161
+ }
8162
+ setEndpointSidePort(edge, end, node, side, toward) {
8163
+ const anchor = this.nearestAnchorOnSide(node, side, toward);
8164
+ if (!anchor) return;
8165
+ const next = {
8166
+ ...end === "from" ? edge.from : edge.to,
8167
+ portId: `${ANCHOR_PORT_PREFIX}${anchor.id}`,
8168
+ outlineParam: void 0
8169
+ };
8170
+ if (end === "from") edge.from = next;
8171
+ else edge.to = next;
8172
+ }
8173
+ /**
7070
8174
  * Resolve a node endpoint:
7071
8175
  * - lockAnchors + portId → fixed side port (wins over leftover outlineParam)
7072
8176
  * - outlineParam → fixed point on outline (attach-to-outline)
@@ -7081,10 +8185,13 @@ var EdgeEndpointUpdater = class {
7081
8185
  direction: anchorId.split(":")[0]
7082
8186
  };
7083
8187
  }
7084
- if (isNodeEdgeEndpoint(endpoint) && endpoint.outlineParam !== void 0) return {
7085
- point: node.getConnectionPointAtOutlineParam(endpoint.outlineParam),
7086
- direction: getDirectionFromOutlineParam(endpoint.outlineParam)
7087
- };
8188
+ if (isNodeEdgeEndpoint(endpoint) && endpoint.outlineParam !== void 0) {
8189
+ const bounds = node.getBounds();
8190
+ return {
8191
+ point: node.getConnectionPointAtOutlineParam(endpoint.outlineParam),
8192
+ direction: getDirectionFromOutlineParam(endpoint.outlineParam, bounds)
8193
+ };
8194
+ }
7088
8195
  if (toward) return this.resolveFloatingEndpoint(node, toward);
7089
8196
  return { point: node.getCenter() };
7090
8197
  }
@@ -8105,11 +9212,13 @@ var DiagramRenderer = class extends EventEmitter {
8105
9212
  }
8106
9213
  getNodeObstacles() {
8107
9214
  if (this._nodeObstaclesCache !== null) return this._nodeObstaclesCache;
9215
+ const pad = 4;
8108
9216
  this._nodeObstaclesCache = Array.from(this._nodes.values()).map((node) => ({
8109
- x: node.x - 8,
8110
- y: node.y - 8,
8111
- width: node.width + 16,
8112
- height: node.height + 16,
9217
+ id: node.id,
9218
+ x: node.x - pad,
9219
+ y: node.y - pad,
9220
+ width: node.width + pad * 2,
9221
+ height: node.height + pad * 2,
8113
9222
  role: "other"
8114
9223
  }));
8115
9224
  return this._nodeObstaclesCache;