@ngroznykh/papirus 0.8.0 → 0.8.2

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;
@@ -1996,12 +2012,13 @@ var ConnectionManager = class extends EventEmitter {
1996
2012
  let role = "other";
1997
2013
  if (node.id === sourceNodeId) role = "source";
1998
2014
  else if (node.id === targetNodeId) role = "target";
2015
+ const pad = 4;
1999
2016
  obstacles.push({
2000
2017
  id: node.id,
2001
- x: node.x - 8,
2002
- y: node.y - 8,
2003
- width: node.width + 16,
2004
- height: node.height + 16,
2018
+ x: node.x - pad,
2019
+ y: node.y - pad,
2020
+ width: node.width + pad * 2,
2021
+ height: node.height + pad * 2,
2005
2022
  role
2006
2023
  });
2007
2024
  }
@@ -2016,7 +2033,10 @@ var ConnectionManager = class extends EventEmitter {
2016
2033
  }
2017
2034
  getTargetDirForReconnect(edge, endpoint) {
2018
2035
  const ep = endpoint === "from" ? edge.from : edge.to;
2019
- 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
+ }
2020
2040
  return (ep.portId?.replace(ANCHOR_PORT_PREFIX, ""))?.split(":")[0];
2021
2041
  }
2022
2042
  /**
@@ -2331,8 +2351,9 @@ var ConnectionManager = class extends EventEmitter {
2331
2351
  ctx.lineTo(end.x, end.y);
2332
2352
  ctx.stroke();
2333
2353
  } else {
2334
- const fromDir = this.sourceOutlineParam !== null ? getDirectionFromOutlineParam(this.sourceOutlineParam) : this.sourceAnchorId?.split(":")[0];
2335
- 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];
2336
2357
  this.drawBezierPreview(ctx, start, end, fromDir, toDir);
2337
2358
  }
2338
2359
  ctx.setLineDash([]);
@@ -3803,6 +3824,9 @@ var StraightPathStrategy = class {
3803
3824
  var DEFAULT_MARGIN = 12;
3804
3825
  var DEFAULT_EXIT = 20;
3805
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;
3806
3830
  function expandRect(r, m) {
3807
3831
  return {
3808
3832
  ...r,
@@ -3812,6 +3836,133 @@ function expandRect(r, m) {
3812
3836
  height: r.height + 2 * m
3813
3837
  };
3814
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
+ }
3815
3966
  function moveByDir$1(p, dir, d) {
3816
3967
  switch (dir) {
3817
3968
  case "top": return {
@@ -3857,11 +4008,41 @@ function segmentHitsObstacles(a, b, obstacles) {
3857
4008
  function manhattan$1(a, b) {
3858
4009
  return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
3859
4010
  }
3860
- /** Push along dir until outside expanded parent (and at least exitDistance). */
3861
- function computeStartExit(from, fromDir, parent, margin, exitDistance) {
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 blocker (and at least exitDistance). */
4042
+ function computeStartExit(from, fromDir, blocker, margin, exitDistance) {
3862
4043
  let p = moveByDir$1(from, fromDir, exitDistance);
3863
- if (!parent) return p;
3864
- const expanded = expandRect(parent, margin);
4044
+ if (!blocker) return p;
4045
+ const expanded = expandRect(blocker, margin);
3865
4046
  let guard = 0;
3866
4047
  while (pointInOpenInterior(p, expanded) && guard++ < 64) p = moveByDir$1(p, fromDir, exitDistance);
3867
4048
  if (pointInOpenInterior(p, expanded)) {
@@ -3895,8 +4076,190 @@ function computeStartExit(from, fromDir, parent, margin, exitDistance) {
3895
4076
  }
3896
4077
  return p;
3897
4078
  }
3898
- function computeEndEntry(to, toDir, exitDistance) {
3899
- return moveByDir$1(to, toDir, exitDistance);
4079
+ function pointInRectClosed(p, r) {
4080
+ return p.x >= r.x && p.x <= r.x + r.width && p.y >= r.y && p.y <= r.y + r.height;
4081
+ }
4082
+ /**
4083
+ * Push a same-side exit/entry far enough that the orthogonal span between
4084
+ * `spanA` and `spanB` clears every `others` obstacle (siblings in a stack).
4085
+ */
4086
+ function clearCorridorOfOthers(exit, dir, others, spanA, spanB, margin) {
4087
+ if (others.length === 0) return exit;
4088
+ let p = { ...exit };
4089
+ const y1 = Math.min(spanA.y, spanB.y);
4090
+ const y2 = Math.max(spanA.y, spanB.y);
4091
+ const x1 = Math.min(spanA.x, spanB.x);
4092
+ const x2 = Math.max(spanA.x, spanB.x);
4093
+ for (let guard = 0; guard < 64; guard++) {
4094
+ let blocked;
4095
+ for (const raw of others) {
4096
+ const o = expandRect(raw, margin);
4097
+ if (dir === "left" || dir === "right") {
4098
+ if (p.x > o.x && p.x < o.x + o.width && y2 > o.y && y1 < o.y + o.height) {
4099
+ blocked = o;
4100
+ break;
4101
+ }
4102
+ } else if (p.y > o.y && p.y < o.y + o.height && x2 > o.x && x1 < o.x + o.width) {
4103
+ blocked = o;
4104
+ break;
4105
+ }
4106
+ }
4107
+ if (!blocked) return p;
4108
+ switch (dir) {
4109
+ case "left":
4110
+ p = {
4111
+ x: blocked.x - 1,
4112
+ y: p.y
4113
+ };
4114
+ break;
4115
+ case "right":
4116
+ p = {
4117
+ x: blocked.x + blocked.width + 1,
4118
+ y: p.y
4119
+ };
4120
+ break;
4121
+ case "top":
4122
+ p = {
4123
+ x: p.x,
4124
+ y: blocked.y - 1
4125
+ };
4126
+ break;
4127
+ case "bottom":
4128
+ p = {
4129
+ x: p.x,
4130
+ y: blocked.y + blocked.height + 1
4131
+ };
4132
+ break;
4133
+ }
4134
+ }
4135
+ return p;
4136
+ }
4137
+ /**
4138
+ * When same-side exits still share an axis that crosses a blocker (top→top over
4139
+ * a stacked target), slide the *start* exit to the nearer free side. Keep
4140
+ * endEntry on the approach ray at `to` so the final stub stays ⊥ to the side.
4141
+ */
4142
+ function offsetSameSideAroundBlockers(startExit, endEntry, dir, blockers, margin, from, to) {
4143
+ if (blockers.length === 0) return {
4144
+ startExit,
4145
+ endEntry
4146
+ };
4147
+ let s = { ...startExit };
4148
+ const e = { ...endEntry };
4149
+ const y1 = Math.min(from.y, to.y, s.y, e.y);
4150
+ const y2 = Math.max(from.y, to.y, s.y, e.y);
4151
+ const x1 = Math.min(from.x, to.x, s.x, e.x);
4152
+ const x2 = Math.max(from.x, to.x, s.x, e.x);
4153
+ for (const raw of blockers) {
4154
+ const o = expandRect(raw, margin);
4155
+ if (dir === "top" || dir === "bottom") {
4156
+ if (!(s.x > o.x && s.x < o.x + o.width && y2 > o.y && y1 < o.y + o.height)) continue;
4157
+ const leftX = o.x - 1;
4158
+ const rightX = o.x + o.width + 1;
4159
+ s = {
4160
+ ...s,
4161
+ x: s.x <= o.x + o.width / 2 ? leftX : rightX
4162
+ };
4163
+ } else {
4164
+ if (!(s.y > o.y && s.y < o.y + o.height && x2 > o.x && x1 < o.x + o.width)) continue;
4165
+ const topY = o.y - 1;
4166
+ const bottomY = o.y + o.height + 1;
4167
+ s = {
4168
+ ...s,
4169
+ y: s.y <= o.y + o.height / 2 ? topY : bottomY
4170
+ };
4171
+ }
4172
+ }
4173
+ return {
4174
+ startExit: s,
4175
+ endEntry: e
4176
+ };
4177
+ }
4178
+ /** True when collapsing same-side exits onto one rail would not pierce blockers. */
4179
+ function canAlignSameSideExits(dir, startExit, endEntry, from, to, blockers, margin) {
4180
+ let alignedStart = { ...startExit };
4181
+ let alignedEnd = { ...endEntry };
4182
+ if (dir === "left") {
4183
+ const x = Math.min(startExit.x, endEntry.x);
4184
+ alignedStart = {
4185
+ ...alignedStart,
4186
+ x
4187
+ };
4188
+ alignedEnd = {
4189
+ ...alignedEnd,
4190
+ x
4191
+ };
4192
+ } else if (dir === "right") {
4193
+ const x = Math.max(startExit.x, endEntry.x);
4194
+ alignedStart = {
4195
+ ...alignedStart,
4196
+ x
4197
+ };
4198
+ alignedEnd = {
4199
+ ...alignedEnd,
4200
+ x
4201
+ };
4202
+ } else if (dir === "top") {
4203
+ const y = Math.min(startExit.y, endEntry.y);
4204
+ alignedStart = {
4205
+ ...alignedStart,
4206
+ y
4207
+ };
4208
+ alignedEnd = {
4209
+ ...alignedEnd,
4210
+ y
4211
+ };
4212
+ } else {
4213
+ const y = Math.max(startExit.y, endEntry.y);
4214
+ alignedStart = {
4215
+ ...alignedStart,
4216
+ y
4217
+ };
4218
+ alignedEnd = {
4219
+ ...alignedEnd,
4220
+ y
4221
+ };
4222
+ }
4223
+ for (const raw of blockers) {
4224
+ const o = expandRect(raw, margin);
4225
+ if (segmentIntersectsOpenInterior(from, alignedStart, o)) return false;
4226
+ if (segmentIntersectsOpenInterior(alignedEnd, to, o)) return false;
4227
+ if (segmentIntersectsOpenInterior(alignedStart, alignedEnd, o)) return false;
4228
+ }
4229
+ return true;
4230
+ }
4231
+ function computeEndEntry(to, toDir, exitDistance, rawTarget, rawNeighbor) {
4232
+ const stub = moveByDir$1(to, toDir, exitDistance);
4233
+ if (!rawTarget || !rawNeighbor) return stub;
4234
+ const mid = facingGapMid(rawTarget, rawNeighbor);
4235
+ if (!mid) return stub;
4236
+ switch (toDir) {
4237
+ case "bottom":
4238
+ if (mid.y > to.y && rawNeighbor.y >= rawTarget.y + rawTarget.height) return {
4239
+ x: to.x,
4240
+ y: mid.y
4241
+ };
4242
+ break;
4243
+ case "top":
4244
+ if (mid.y < to.y && rawNeighbor.y + rawNeighbor.height <= rawTarget.y) return {
4245
+ x: to.x,
4246
+ y: mid.y
4247
+ };
4248
+ break;
4249
+ case "right":
4250
+ if (mid.x > to.x && rawNeighbor.x >= rawTarget.x + rawTarget.width) return {
4251
+ x: mid.x,
4252
+ y: to.y
4253
+ };
4254
+ break;
4255
+ case "left":
4256
+ if (mid.x < to.x && rawNeighbor.x + rawNeighbor.width <= rawTarget.x) return {
4257
+ x: mid.x,
4258
+ y: to.y
4259
+ };
4260
+ break;
4261
+ }
4262
+ return stub;
3900
4263
  }
3901
4264
  /**
3902
4265
  * When nodes face each other closer than 2×exitDistance, fixed stubs cross
@@ -3972,8 +4335,12 @@ function simplifyPath$1(path) {
3972
4335
  const a = out[i];
3973
4336
  const b = out[i + 1];
3974
4337
  const c = out[i + 2];
3975
- const colinear = Math.abs(a.x - b.x) < .001 && Math.abs(b.x - c.x) < .001 || Math.abs(a.y - b.y) < .001 && Math.abs(b.y - c.y) < .001;
3976
- if (Math.abs(a.x - b.x) < .001 && Math.abs(b.x - c.x) < .001 && (a.y < c.y && c.y < b.y || b.y < c.y && c.y < a.y) || Math.abs(a.y - b.y) < .001 && Math.abs(b.y - c.y) < .001 && (a.x < c.x && c.x < b.x || b.x < c.x && c.x < a.x) || colinear && i > 0 && i + 2 < out.length - 1) out.splice(i + 1, 1);
4338
+ const colinearV = Math.abs(a.x - b.x) < .001 && Math.abs(b.x - c.x) < .001;
4339
+ const colinearH = Math.abs(a.y - b.y) < .001 && Math.abs(b.y - c.y) < .001;
4340
+ const colinear = colinearV || colinearH;
4341
+ 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);
4342
+ 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);
4343
+ if (uturn || spur || colinear && i > 0 && i + 2 < out.length - 1) out.splice(i + 1, 1);
3977
4344
  else i++;
3978
4345
  }
3979
4346
  return out;
@@ -4009,13 +4376,11 @@ function collectLatticeCoords(startExit, endEntry, obstacles, parent, target, ma
4009
4376
  ySet.add(o.y - 1);
4010
4377
  ySet.add(o.y + o.height + 1);
4011
4378
  }
4012
- if (parent && target && margin != null) {
4013
- const ep = expandRect(parent, margin);
4014
- const et = expandRect(target, margin);
4379
+ if (parent && target) {
4015
4380
  const addGapY = (gapY) => {
4016
4381
  ySet.add(gapY);
4017
- const left = Math.min(ep.x, et.x, startExit.x, endEntry.x);
4018
- const right = Math.max(ep.x + ep.width, et.x + et.width, startExit.x, endEntry.x);
4382
+ const left = Math.min(parent.x, target.x, startExit.x, endEntry.x);
4383
+ const right = Math.max(parent.x + parent.width, target.x + target.width, startExit.x, endEntry.x);
4019
4384
  xSet.add(left);
4020
4385
  xSet.add(right);
4021
4386
  xSet.add(startExit.x);
@@ -4023,17 +4388,24 @@ function collectLatticeCoords(startExit, endEntry, obstacles, parent, target, ma
4023
4388
  };
4024
4389
  const addGapX = (gapX) => {
4025
4390
  xSet.add(gapX);
4026
- const top = Math.min(ep.y, et.y, startExit.y, endEntry.y);
4027
- const bottom = Math.max(ep.y + ep.height, et.y + et.height, startExit.y, endEntry.y);
4391
+ const top = Math.min(parent.y, target.y, startExit.y, endEntry.y);
4392
+ const bottom = Math.max(parent.y + parent.height, target.y + target.height, startExit.y, endEntry.y);
4028
4393
  ySet.add(top);
4029
4394
  ySet.add(bottom);
4030
4395
  ySet.add(startExit.y);
4031
4396
  ySet.add(endEntry.y);
4032
4397
  };
4033
- if (et.y + et.height < ep.y) addGapY((et.y + et.height + ep.y) / 2);
4034
- if (ep.y + ep.height < et.y) addGapY((ep.y + ep.height + et.y) / 2);
4035
- if (et.x + et.width < ep.x) addGapX((et.x + et.width + ep.x) / 2);
4036
- if (ep.x + ep.width < et.x) addGapX((ep.x + ep.width + et.x) / 2);
4398
+ const mid = facingGapMid(parent, target);
4399
+ if (mid) if (parent.y + parent.height < target.y || target.y + target.height < parent.y) addGapY(mid.y);
4400
+ else addGapX(mid.x);
4401
+ else if (margin != null) {
4402
+ const ep = expandRect(parent, margin);
4403
+ const et = expandRect(target, margin);
4404
+ if (et.y + et.height < ep.y) addGapY((et.y + et.height + ep.y) / 2);
4405
+ if (ep.y + ep.height < et.y) addGapY((ep.y + ep.height + et.y) / 2);
4406
+ if (et.x + et.width < ep.x) addGapX((et.x + et.width + ep.x) / 2);
4407
+ if (ep.x + ep.width < et.x) addGapX((ep.x + ep.width + et.x) / 2);
4408
+ }
4037
4409
  }
4038
4410
  return {
4039
4411
  xs: [...xSet].sort((a, b) => a - b),
@@ -4089,7 +4461,7 @@ function buildCornerGraph(startExit, endEntry, obstacles, parent, target, margin
4089
4461
  const a = nodes[list[i - 1]];
4090
4462
  const b = nodes[list[i]];
4091
4463
  if (!segmentHitsObstacles(a, b, obstacles)) {
4092
- const length = manhattan$1(a, b);
4464
+ const length = manhattan$1(a, b) + contourProximityPenalty(a, b, obstacles);
4093
4465
  pushEdge(list[i - 1], list[i], "v", length);
4094
4466
  pushEdge(list[i], list[i - 1], "v", length);
4095
4467
  }
@@ -4101,7 +4473,7 @@ function buildCornerGraph(startExit, endEntry, obstacles, parent, target, margin
4101
4473
  const a = nodes[list[i - 1]];
4102
4474
  const b = nodes[list[i]];
4103
4475
  if (!segmentHitsObstacles(a, b, obstacles)) {
4104
- const length = manhattan$1(a, b);
4476
+ const length = manhattan$1(a, b) + contourProximityPenalty(a, b, obstacles);
4105
4477
  pushEdge(list[i - 1], list[i], "h", length);
4106
4478
  pushEdge(list[i], list[i - 1], "h", length);
4107
4479
  }
@@ -4169,18 +4541,20 @@ function pathIsValid(path, obstacles) {
4169
4541
  for (let i = 1; i < path.length; i++) if (segmentHitsObstacles(path[i - 1], path[i], obstacles)) return false;
4170
4542
  return true;
4171
4543
  }
4544
+ /** Always orthogonal: never emit a diagonal between startExit and endEntry. */
4545
+ function orthogonalElbow(startExit, endEntry) {
4546
+ if (Math.abs(startExit.x - endEntry.x) < .001 || Math.abs(startExit.y - endEntry.y) < .001) return [startExit, endEntry];
4547
+ return [
4548
+ startExit,
4549
+ {
4550
+ x: startExit.x,
4551
+ y: endEntry.y
4552
+ },
4553
+ endEntry
4554
+ ];
4555
+ }
4172
4556
  function fallbackOuterRoute(startExit, endEntry, obstacles) {
4173
- if (obstacles.length === 0) {
4174
- if (Math.abs(startExit.x - endEntry.x) < .001 || Math.abs(startExit.y - endEntry.y) < .001) return [startExit, endEntry];
4175
- return [
4176
- startExit,
4177
- {
4178
- x: startExit.x,
4179
- y: endEntry.y
4180
- },
4181
- endEntry
4182
- ];
4183
- }
4557
+ if (obstacles.length === 0) return orthogonalElbow(startExit, endEntry);
4184
4558
  const u = unionBBox(obstacles);
4185
4559
  const pad = 1;
4186
4560
  const top = u.y - pad;
@@ -4235,6 +4609,22 @@ function fallbackOuterRoute(startExit, endEntry, obstacles) {
4235
4609
  y: endEntry.y
4236
4610
  },
4237
4611
  endEntry
4612
+ ],
4613
+ [
4614
+ startExit,
4615
+ {
4616
+ x: startExit.x,
4617
+ y: endEntry.y
4618
+ },
4619
+ endEntry
4620
+ ],
4621
+ [
4622
+ startExit,
4623
+ {
4624
+ x: endEntry.x,
4625
+ y: startExit.y
4626
+ },
4627
+ endEntry
4238
4628
  ]
4239
4629
  ];
4240
4630
  let best = null;
@@ -4251,14 +4641,7 @@ function fallbackOuterRoute(startExit, endEntry, obstacles) {
4251
4641
  best = simplified;
4252
4642
  }
4253
4643
  }
4254
- return best ?? [
4255
- startExit,
4256
- {
4257
- x: startExit.x,
4258
- y: endEntry.y
4259
- },
4260
- endEntry
4261
- ];
4644
+ return best ?? orthogonalElbow(startExit, endEntry);
4262
4645
  }
4263
4646
  function routeMidPath(startExit, endEntry, obstacles, parent, target, margin) {
4264
4647
  if (Math.abs(startExit.x - endEntry.x) < .001 || Math.abs(startExit.y - endEntry.y) < .001) {
@@ -4275,39 +4658,231 @@ function routeMidPath(startExit, endEntry, obstacles, parent, target, margin) {
4275
4658
  function routeOrthogonalAround(input) {
4276
4659
  const margin = input.margin ?? DEFAULT_MARGIN;
4277
4660
  const exitDistance = input.exitDistance ?? DEFAULT_EXIT;
4278
- const exitBlocker = input.parent ?? input.source;
4279
- const rawStartExit = computeStartExit(input.from, input.fromDir, exitBlocker, margin, exitDistance);
4280
- const rawEndEntry = computeEndEntry(input.to, input.toDir, exitDistance);
4661
+ const others = input.others ?? [];
4662
+ const internalParent = !!input.parent && pointInRectClosed(input.from, input.parent) && pointInRectClosed(input.to, input.parent);
4663
+ const exitBlocker = internalParent ? input.source : input.parent ?? input.source;
4664
+ let rawStartExit = computeStartExit(input.from, input.fromDir, exitBlocker, margin, exitDistance);
4665
+ const gapNeighbor = internalParent ? input.source : input.parent ?? input.source;
4666
+ const peeledTargetNeighbor = input.target && gapNeighbor ? peelForFacingGap(input.target, gapNeighbor) : null;
4667
+ let rawEndEntry = computeEndEntry(input.to, input.toDir, exitDistance, peeledTargetNeighbor?.a, peeledTargetNeighbor?.b);
4668
+ if (input.fromDir === input.toDir) {
4669
+ const sameSideBlockers = [...others];
4670
+ if (input.target) sameSideBlockers.push(input.target);
4671
+ if (input.source) sameSideBlockers.push(input.source);
4672
+ if (sameSideBlockers.length > 0) {
4673
+ rawStartExit = clearCorridorOfOthers(rawStartExit, input.fromDir, sameSideBlockers, input.from, input.to, margin);
4674
+ rawEndEntry = clearCorridorOfOthers(rawEndEntry, input.toDir, sameSideBlockers, input.from, input.to, margin);
4675
+ ({startExit: rawStartExit, endEntry: rawEndEntry} = offsetSameSideAroundBlockers(rawStartExit, rawEndEntry, input.fromDir, sameSideBlockers, margin, input.from, input.to));
4676
+ }
4677
+ if (canAlignSameSideExits(input.fromDir, rawStartExit, rawEndEntry, input.from, input.to, sameSideBlockers.length > 0 ? sameSideBlockers : [input.target, input.source].filter((r) => !!r), margin)) {
4678
+ if (input.fromDir === "left") {
4679
+ const x = Math.min(rawStartExit.x, rawEndEntry.x);
4680
+ rawStartExit = {
4681
+ ...rawStartExit,
4682
+ x
4683
+ };
4684
+ rawEndEntry = {
4685
+ ...rawEndEntry,
4686
+ x
4687
+ };
4688
+ } else if (input.fromDir === "right") {
4689
+ const x = Math.max(rawStartExit.x, rawEndEntry.x);
4690
+ rawStartExit = {
4691
+ ...rawStartExit,
4692
+ x
4693
+ };
4694
+ rawEndEntry = {
4695
+ ...rawEndEntry,
4696
+ x
4697
+ };
4698
+ } else if (input.fromDir === "top") {
4699
+ const y = Math.min(rawStartExit.y, rawEndEntry.y);
4700
+ rawStartExit = {
4701
+ ...rawStartExit,
4702
+ y
4703
+ };
4704
+ rawEndEntry = {
4705
+ ...rawEndEntry,
4706
+ y
4707
+ };
4708
+ } else if (input.fromDir === "bottom") {
4709
+ const y = Math.max(rawStartExit.y, rawEndEntry.y);
4710
+ rawStartExit = {
4711
+ ...rawStartExit,
4712
+ y
4713
+ };
4714
+ rawEndEntry = {
4715
+ ...rawEndEntry,
4716
+ y
4717
+ };
4718
+ }
4719
+ }
4720
+ }
4281
4721
  const { startExit, endEntry } = clampFacingExitEntry(input.from, input.to, input.fromDir, input.toDir, rawStartExit, rawEndEntry);
4722
+ let sourceObs = input.source ? expandRect(input.source, margin) : void 0;
4723
+ let parentObs = input.parent && !internalParent ? expandRect(input.parent, margin) : void 0;
4724
+ let targetObs = input.target ? expandRect(input.target, margin) : void 0;
4725
+ const openCorridor = (expA, expB, inputA, inputB) => {
4726
+ const peeled = peelForFacingGap(inputA, inputB);
4727
+ if (peeled) return preserveFacingGap(expA, expB, peeled.a, peeled.b);
4728
+ return preserveFacingGap(expA, expB, inputA, inputB);
4729
+ };
4730
+ if (parentObs && targetObs && input.parent && input.target) [parentObs, targetObs] = openCorridor(parentObs, targetObs, input.parent, input.target);
4731
+ else if (sourceObs && targetObs && input.source && input.target) [sourceObs, targetObs] = openCorridor(sourceObs, targetObs, input.source, input.target);
4732
+ if (sourceObs && parentObs && input.source && input.parent) [sourceObs, parentObs] = openCorridor(sourceObs, parentObs, input.source, input.parent);
4282
4733
  const obstacles = [];
4283
- if (input.source) obstacles.push(expandRect(input.source, margin));
4284
- if (input.parent) obstacles.push(expandRect(input.parent, margin));
4285
- if (input.target) obstacles.push(expandRect(input.target, margin));
4286
- const mid = routeMidPath(startExit, endEntry, obstacles, input.parent ?? input.source, input.target, margin).slice(1, -1);
4287
- return simplifyPath$1([
4734
+ if (sourceObs) obstacles.push(sourceObs);
4735
+ if (parentObs) obstacles.push(parentObs);
4736
+ if (targetObs) obstacles.push(targetObs);
4737
+ for (const other of others) obstacles.push(expandRect(other, margin));
4738
+ const peeledLattice = !internalParent && input.parent && input.target ? peelForFacingGap(input.parent, input.target) : input.source && input.target ? peelForFacingGap(input.source, input.target) : null;
4739
+ const mid = routeMidPath(startExit, endEntry, obstacles, peeledLattice?.a ?? (internalParent ? input.source : input.parent ?? input.source), peeledLattice?.b ?? input.target, margin).slice(1, -1);
4740
+ const lastMid = mid[mid.length - 1];
4741
+ const alreadyApproaching = !!lastMid && isOnApproachOutside(lastMid, input.to, input.toDir, 1);
4742
+ return simplifyPath$1(ensureOrthogonalPath([
4288
4743
  input.from,
4289
4744
  startExit,
4290
4745
  ...mid,
4291
- endEntry,
4746
+ ...alreadyApproaching ? [] : [endEntry],
4292
4747
  input.to
4293
- ]);
4748
+ ], {
4749
+ exitDir: input.fromDir,
4750
+ enterDir: input.toDir
4751
+ }));
4752
+ }
4753
+ /** Elbow that leaves `prev` along `dir` first (source exit). */
4754
+ function elbowExitFirst(prev, next, dir) {
4755
+ switch (dir) {
4756
+ case "left":
4757
+ case "right": return {
4758
+ x: next.x,
4759
+ y: prev.y
4760
+ };
4761
+ case "top":
4762
+ case "bottom": return {
4763
+ x: prev.x,
4764
+ y: next.y
4765
+ };
4766
+ }
4767
+ }
4768
+ /** Elbow that arrives at `next` along `dir` last (target entry). */
4769
+ function elbowEnterLast(prev, next, dir) {
4770
+ switch (dir) {
4771
+ case "left":
4772
+ case "right": return {
4773
+ x: prev.x,
4774
+ y: next.y
4775
+ };
4776
+ case "top":
4777
+ case "bottom": return {
4778
+ x: next.x,
4779
+ y: prev.y
4780
+ };
4781
+ }
4782
+ }
4783
+ /** Insert elbows so no consecutive points form a diagonal (safety net). */
4784
+ function ensureOrthogonalPath(path, options) {
4785
+ if (path.length < 2) return path;
4786
+ const out = [path[0]];
4787
+ for (let i = 1; i < path.length; i++) {
4788
+ const prev = out[out.length - 1];
4789
+ const next = path[i];
4790
+ if (!(Math.abs(prev.x - next.x) < .001 || Math.abs(prev.y - next.y) < .001)) {
4791
+ const isFirst = out.length === 1;
4792
+ const isLast = i === path.length - 1;
4793
+ let elbow;
4794
+ if (isFirst && options?.exitDir) elbow = elbowExitFirst(prev, next, options.exitDir);
4795
+ else if (isLast && options?.enterDir) elbow = elbowEnterLast(prev, next, options.enterDir);
4796
+ else elbow = {
4797
+ x: prev.x,
4798
+ y: next.y
4799
+ };
4800
+ out.push(elbow);
4801
+ }
4802
+ out.push(next);
4803
+ }
4804
+ return out;
4805
+ }
4806
+ function isOnApproachOutside(p, to, toDir, minClearance) {
4807
+ switch (toDir) {
4808
+ case "top": return Math.abs(p.x - to.x) < .001 && p.y <= to.y - minClearance;
4809
+ case "bottom": return Math.abs(p.x - to.x) < .001 && p.y >= to.y + minClearance;
4810
+ case "left": return Math.abs(p.y - to.y) < .001 && p.x <= to.x - minClearance;
4811
+ case "right": return Math.abs(p.y - to.y) < .001 && p.x >= to.x + minClearance;
4812
+ }
4813
+ }
4814
+ //#endregion
4815
+ //#region src/utils/routeDebug.ts
4816
+ function isRouteDebugEnabled() {
4817
+ if (typeof globalThis === "undefined") return false;
4818
+ const g = globalThis;
4819
+ if (g.__PAPIRUS_ROUTE_DEBUG__ === true || g.__PAPIRUS_ROUTE_DEBUG__ === 1) return true;
4820
+ try {
4821
+ if (typeof localStorage !== "undefined" && localStorage.getItem("papirusRouteDebug") === "1") return true;
4822
+ } catch {}
4823
+ return false;
4824
+ }
4825
+ function routeDebug(tag, payload) {
4826
+ if (!isRouteDebugEnabled()) return;
4827
+ console.log(`[papirus:route] ${tag}`, payload);
4294
4828
  }
4295
4829
  //#endregion
4296
4830
  //#region src/elements/paths/PolylinePathStrategy.ts
4297
4831
  function isSide(v) {
4298
4832
  return v === "top" || v === "right" || v === "bottom" || v === "left";
4299
4833
  }
4834
+ function pointInObstacle(p, o) {
4835
+ return p.x >= o.x && p.x <= o.x + o.width && p.y >= o.y && p.y <= o.y + o.height;
4836
+ }
4837
+ function pointOnObstacleOutline(p, o, tol = 1) {
4838
+ const onVertical = (Math.abs(p.x - o.x) < tol || Math.abs(p.x - (o.x + o.width)) < tol) && p.y >= o.y - tol && p.y <= o.y + o.height + tol;
4839
+ const onHorizontal = (Math.abs(p.y - o.y) < tol || Math.abs(p.y - (o.y + o.height)) < tol) && p.x >= o.x - tol && p.x <= o.x + o.width + tol;
4840
+ return onVertical || onHorizontal;
4841
+ }
4842
+ /** Prefer the tightest rect that contains `p` (source/composite vs ancestor). */
4843
+ function smallestContaining(p, obstacles, exclude) {
4844
+ let best;
4845
+ let bestArea = Infinity;
4846
+ for (const o of obstacles) {
4847
+ if (exclude.has(o) || !pointInObstacle(p, o)) continue;
4848
+ const area = o.width * o.height;
4849
+ if (area < bestArea) {
4850
+ best = o;
4851
+ bestArea = area;
4852
+ }
4853
+ }
4854
+ return best;
4855
+ }
4300
4856
  function resolveRoutingRoles(from, to, obstacles) {
4301
- const target = obstacles.find((o) => o.role === "target") ?? obstacles.find((o) => {
4302
- 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;
4303
- 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;
4304
- return onVertical || onHorizontal;
4305
- });
4306
- const source = obstacles.find((o) => o.role === "source" && o !== target);
4857
+ const target = obstacles.find((o) => o.role === "target") ?? obstacles.find((o) => pointOnObstacleOutline(to, o));
4858
+ const excludeForSource = /* @__PURE__ */ new Set();
4859
+ if (target) excludeForSource.add(target);
4860
+ const source = obstacles.find((o) => o.role === "source" && o !== target) ?? smallestContaining(from, obstacles, excludeForSource);
4861
+ const excludeForParent = /* @__PURE__ */ new Set();
4862
+ if (target) excludeForParent.add(target);
4863
+ if (source) excludeForParent.add(source);
4864
+ const parent = smallestContaining(from, obstacles, excludeForParent);
4865
+ const spanPad = 40;
4866
+ const span = {
4867
+ x: Math.min(from.x, to.x) - spanPad,
4868
+ y: Math.min(from.y, to.y) - spanPad,
4869
+ width: Math.abs(to.x - from.x) + spanPad * 2,
4870
+ height: Math.abs(to.y - from.y) + spanPad * 2
4871
+ };
4872
+ const rectsOverlap = (a, b) => a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
4307
4873
  return {
4308
- 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),
4874
+ parent,
4309
4875
  source,
4310
- target
4876
+ target,
4877
+ others: obstacles.filter((o) => {
4878
+ if (o === parent || o === source || o === target) return false;
4879
+ if (pointInObstacle(from, o) && pointInObstacle(to, o)) return false;
4880
+ if (parent) return pointInObstacle({
4881
+ x: o.x + o.width / 2,
4882
+ y: o.y + o.height / 2
4883
+ }, parent);
4884
+ return rectsOverlap(o, span);
4885
+ })
4311
4886
  };
4312
4887
  }
4313
4888
  var MIN_SEGMENT_LENGTH = 20;
@@ -4417,10 +4992,11 @@ function inferApproachDir(from, to) {
4417
4992
  }
4418
4993
  /** Which side of `target` the attachment point `to` lies on (border hit). */
4419
4994
  function sideOfPointOnRect(to, target) {
4420
- const onLeft = Math.abs(to.x - target.x) <= 1;
4421
- const onRight = Math.abs(to.x - (target.x + target.width)) <= 1;
4422
- const onTop = Math.abs(to.y - target.y) <= 1;
4423
- const onBottom = Math.abs(to.y - (target.y + target.height)) <= 1;
4995
+ const tol = 6;
4996
+ const onLeft = Math.abs(to.x - target.x) <= tol;
4997
+ const onRight = Math.abs(to.x - (target.x + target.width)) <= tol;
4998
+ const onTop = Math.abs(to.y - target.y) <= tol;
4999
+ const onBottom = Math.abs(to.y - (target.y + target.height)) <= tol;
4424
5000
  if (onTop && !onLeft && !onRight) return "top";
4425
5001
  if (onBottom && !onLeft && !onRight) return "bottom";
4426
5002
  if (onLeft && !onTop && !onBottom) return "left";
@@ -4591,12 +5167,11 @@ var PolylinePathStrategy = class {
4591
5167
  calculatePath(from, to, fromDir, toDir, _options) {
4592
5168
  const controlPoints = _options?.controlPoints;
4593
5169
  if (_options?.editablePolyline) {
4594
- if (controlPoints && controlPoints.length > 0) return [
5170
+ const path = controlPoints && controlPoints.length > 0 ? [
4595
5171
  from,
4596
5172
  ...controlPoints,
4597
5173
  to
4598
- ];
4599
- return [
5174
+ ] : [
4600
5175
  from,
4601
5176
  {
4602
5177
  x: (from.x + to.x) / 2,
@@ -4604,10 +5179,32 @@ var PolylinePathStrategy = class {
4604
5179
  },
4605
5180
  to
4606
5181
  ];
5182
+ routeDebug("polyline.via", {
5183
+ via: "editable",
5184
+ path
5185
+ });
5186
+ return path;
4607
5187
  }
4608
5188
  const dist = distance(from, to);
4609
5189
  const selfLoop = _options?.selfLoop ?? false;
4610
5190
  const obstacles = _options?.obstacles ?? [];
5191
+ const finish = (via, path) => {
5192
+ const roles = resolveRoutingRoles(from, to, obstacles);
5193
+ routeDebug("polyline.via", {
5194
+ via,
5195
+ fromDir,
5196
+ toDir,
5197
+ approachDir: roles.target ? resolveApproachDir(from, to, toDir, roles.target) : toDir,
5198
+ roles: {
5199
+ parent: roles.parent?.id,
5200
+ source: roles.source?.id,
5201
+ target: roles.target?.id
5202
+ },
5203
+ obstacleRoles: obstacles.map((o) => `${o.id}:${o.role ?? "?"}`),
5204
+ path: path.map((p) => `${Math.round(p.x)},${Math.round(p.y)}`).join(" → ")
5205
+ });
5206
+ return path;
5207
+ };
4611
5208
  if (selfLoop && dist < SELF_LOOP_MIN_DISTANCE$1 && (fromDir || toDir)) switch (fromDir ?? toDir) {
4612
5209
  case "bottom": return [
4613
5210
  from,
@@ -4736,9 +5333,9 @@ var PolylinePathStrategy = class {
4736
5333
  ];
4737
5334
  }
4738
5335
  if (!selfLoop && isSide(fromDir) && isSide(toDir)) {
4739
- const { parent, source, target } = resolveRoutingRoles(from, to, obstacles);
5336
+ const { parent, source, target, others } = resolveRoutingRoles(from, to, obstacles);
4740
5337
  const approachDir = resolveApproachDir(from, to, toDir, target);
4741
- if ((parent || source || target) && isSide(approachDir)) return routeOrthogonalAround({
5338
+ if ((parent || source || target) && isSide(approachDir)) return finish("routeOrthogonalAround", routeOrthogonalAround({
4742
5339
  from,
4743
5340
  to,
4744
5341
  fromDir,
@@ -4746,17 +5343,31 @@ var PolylinePathStrategy = class {
4746
5343
  parent,
4747
5344
  source,
4748
5345
  target,
5346
+ others,
4749
5347
  margin: 4,
4750
5348
  exitDistance: 20
5349
+ }));
5350
+ routeDebug("polyline.via", {
5351
+ via: "roles-missed-around",
5352
+ fromDir,
5353
+ toDir,
5354
+ approachDir,
5355
+ roles: {
5356
+ parent: parent?.id,
5357
+ source: source?.id,
5358
+ target: target?.id,
5359
+ others: others.map((o) => o.id).filter(Boolean)
5360
+ },
5361
+ obstacleRoles: obstacles.map((o) => `${o.id}:${o.role ?? "?"}`)
4751
5362
  });
4752
5363
  }
4753
5364
  if (!selfLoop && dist < OBSTACLE_ROUTING_DISTANCE) {
4754
5365
  const routed = buildRoutedPolyline(from, to, fromDir, toDir, obstacles);
4755
- if (routed) return routed;
5366
+ if (routed) return finish("buildRoutedPolyline", routed);
4756
5367
  }
4757
- if (fromDir || toDir) return this.calculateDirectedPath(from, to, fromDir, toDir);
5368
+ if (fromDir || toDir) return finish("calculateDirectedPath", this.calculateDirectedPath(from, to, fromDir, toDir));
4758
5369
  const midX = (from.x + to.x) / 2;
4759
- if (Math.abs(to.x - from.x) > Math.abs(to.y - from.y)) return [
5370
+ if (Math.abs(to.x - from.x) > Math.abs(to.y - from.y)) return finish("manhattan-h", [
4760
5371
  from,
4761
5372
  {
4762
5373
  x: midX,
@@ -4767,9 +5378,9 @@ var PolylinePathStrategy = class {
4767
5378
  y: to.y
4768
5379
  },
4769
5380
  to
4770
- ];
5381
+ ]);
4771
5382
  const midY = (from.y + to.y) / 2;
4772
- return [
5383
+ return finish("manhattan-v", [
4773
5384
  from,
4774
5385
  {
4775
5386
  x: from.x,
@@ -4780,7 +5391,7 @@ var PolylinePathStrategy = class {
4780
5391
  y: midY
4781
5392
  },
4782
5393
  to
4783
- ];
5394
+ ]);
4784
5395
  }
4785
5396
  calculateDirectedPath(from, to, fromDir, toDir) {
4786
5397
  const points = [from];
@@ -5482,6 +6093,14 @@ function getEdgeLabelRotation(options) {
5482
6093
  return angle;
5483
6094
  }
5484
6095
  //#endregion
6096
+ //#region src/types.ts
6097
+ function isNodeEdgeEndpoint(endpoint) {
6098
+ return typeof endpoint.nodeId === "string" && endpoint.nodeId.length > 0;
6099
+ }
6100
+ function isEdgeEdgeEndpoint(endpoint) {
6101
+ return typeof endpoint.edgeId === "string" && endpoint.edgeId.length > 0;
6102
+ }
6103
+ //#endregion
5485
6104
  //#region src/elements/Edge.ts
5486
6105
  var DEFAULT_EDGE_STYLE = {
5487
6106
  strokeColor: "#666666",
@@ -5851,6 +6470,11 @@ var Edge = class extends Element {
5851
6470
  const routingOptionsChanged = !routingOptionsEqual(this._pathOptions, options);
5852
6471
  if (geometryUnchanged && !routingOptionsChanged) {
5853
6472
  this._pathOptions = options;
6473
+ routeDebug("edge.updateEndpoints:skip", {
6474
+ id: this.id,
6475
+ type: this._type,
6476
+ reason: "geometry+routing unchanged"
6477
+ });
5854
6478
  return;
5855
6479
  }
5856
6480
  this._fromPoint = fromPoint;
@@ -5870,6 +6494,51 @@ var Edge = class extends Element {
5870
6494
  editablePolyline: this.isEditablePolyline(),
5871
6495
  selfLoop: this._from.nodeId === this._to.nodeId
5872
6496
  });
6497
+ if (this._type === "polyline" || this._type === "editable-polyline" || this._type === "bezier") {
6498
+ const obstacles = this._pathOptions?.obstacles ?? [];
6499
+ const segs = [];
6500
+ for (let i = 1; i < this._path.length; i++) {
6501
+ const a = this._path[i - 1];
6502
+ const b = this._path[i];
6503
+ const dx = b.x - a.x;
6504
+ const dy = b.y - a.y;
6505
+ const axis = Math.abs(dy) < .01 ? "H" : Math.abs(dx) < .01 ? "V" : `DIAG(${dx.toFixed(0)},${dy.toFixed(0)})`;
6506
+ segs.push({
6507
+ axis,
6508
+ len: Math.round(Math.abs(dx) + Math.abs(dy)),
6509
+ a: `${Math.round(a.x)},${Math.round(a.y)}`,
6510
+ b: `${Math.round(b.x)},${Math.round(b.y)}`
6511
+ });
6512
+ }
6513
+ routeDebug("edge.recalculatePath", {
6514
+ id: this.id,
6515
+ type: this._type,
6516
+ from: {
6517
+ nodeId: this._from.nodeId,
6518
+ portId: isNodeEdgeEndpoint(this._from) ? this._from.portId : void 0,
6519
+ outlineParam: isNodeEdgeEndpoint(this._from) ? this._from.outlineParam : void 0,
6520
+ point: `${Math.round(this._fromPoint.x)},${Math.round(this._fromPoint.y)}`,
6521
+ dir: this._fromDir
6522
+ },
6523
+ to: {
6524
+ nodeId: this._to.nodeId,
6525
+ portId: isNodeEdgeEndpoint(this._to) ? this._to.portId : void 0,
6526
+ outlineParam: isNodeEdgeEndpoint(this._to) ? this._to.outlineParam : void 0,
6527
+ point: `${Math.round(this._toPoint.x)},${Math.round(this._toPoint.y)}`,
6528
+ dir: this._toDir
6529
+ },
6530
+ lockAnchors: this.lockAnchors,
6531
+ obstacleCount: obstacles.length,
6532
+ obstacles: obstacles.map((o) => ({
6533
+ id: o.id,
6534
+ role: o.role,
6535
+ box: `${Math.round(o.x)},${Math.round(o.y)} ${Math.round(o.width)}x${Math.round(o.height)}`
6536
+ })),
6537
+ path: this._path.map((p) => `${Math.round(p.x)},${Math.round(p.y)}`).join(" → "),
6538
+ segments: segs,
6539
+ controlPoints: this._controlPoints?.length ?? 0
6540
+ });
6541
+ }
5873
6542
  this.updateBounds();
5874
6543
  this.markDirty();
5875
6544
  }
@@ -7597,14 +8266,6 @@ var ContextMenuManager = class extends EventEmitter {
7597
8266
  }
7598
8267
  };
7599
8268
  //#endregion
7600
- //#region src/types.ts
7601
- function isNodeEdgeEndpoint(endpoint) {
7602
- return typeof endpoint.nodeId === "string" && endpoint.nodeId.length > 0;
7603
- }
7604
- function isEdgeEdgeEndpoint(endpoint) {
7605
- return typeof endpoint.edgeId === "string" && endpoint.edgeId.length > 0;
7606
- }
7607
- //#endregion
7608
8269
  //#region src/core/EdgeEndpointUpdater.ts
7609
8270
  var EDGE_ATTACH_MAX_PASSES = 3;
7610
8271
  var EdgeEndpointUpdater = class {
@@ -7612,7 +8273,7 @@ var EdgeEndpointUpdater = class {
7612
8273
  this.host = host;
7613
8274
  }
7614
8275
  updateForDrag() {
7615
- this.update(false);
8276
+ this.update(true);
7616
8277
  }
7617
8278
  updateAll() {
7618
8279
  this.update(true);
@@ -7640,7 +8301,10 @@ var EdgeEndpointUpdater = class {
7640
8301
  const fromNode = nodes.get(edge.from.nodeId);
7641
8302
  const toNode = nodes.get(edge.to.nodeId);
7642
8303
  if (!fromNode || !toNode) return;
7643
- if (includeRoutingObstacles) this.lockAnchors(edge, fromNode, toNode);
8304
+ if (includeRoutingObstacles) {
8305
+ this.preferFacingSidePorts(edge, fromNode, toNode);
8306
+ this.lockAnchors(edge, fromNode, toNode);
8307
+ }
7644
8308
  const from = this.resolveNodeEndpoint(fromNode, edge.from, toNode.getCenter(), edge.lockAnchors);
7645
8309
  const to = this.resolveNodeEndpoint(toNode, edge.to, fromNode.getCenter(), edge.lockAnchors);
7646
8310
  const fromId = edge.from.nodeId;
@@ -7656,6 +8320,27 @@ var EdgeEndpointUpdater = class {
7656
8320
  };
7657
8321
  return obstacle;
7658
8322
  });
8323
+ routeDebug("updater.nodeNode", {
8324
+ edgeId: edge.id,
8325
+ type: edge.type,
8326
+ includeRoutingObstacles,
8327
+ lockAnchors: edge.lockAnchors,
8328
+ from: {
8329
+ nodeId: fromId,
8330
+ portId: edge.from.portId,
8331
+ outlineParam: edge.from.outlineParam,
8332
+ point: `${Math.round(from.point.x)},${Math.round(from.point.y)}`,
8333
+ dir: from.direction
8334
+ },
8335
+ to: {
8336
+ nodeId: toId,
8337
+ portId: edge.to.portId,
8338
+ outlineParam: edge.to.outlineParam,
8339
+ point: `${Math.round(to.point.x)},${Math.round(to.point.y)}`,
8340
+ dir: to.direction
8341
+ },
8342
+ obstacleCount: routingObstacles?.length ?? 0
8343
+ });
7659
8344
  edge.updateEndpoints(from.point, to.point, from.direction, to.direction, routingObstacles ? { obstacles: routingObstacles } : void 0);
7660
8345
  }
7661
8346
  /**
@@ -7706,6 +8391,88 @@ var EdgeEndpointUpdater = class {
7706
8391
  }
7707
8392
  }
7708
8393
  /**
8394
+ * When nodes are clearly stacked, retarget unset / OEF-lateral ports toward
8395
+ * facing sides. Intentional same-side wraps (bottom→bottom, top→top, left→left)
8396
+ * are kept — orthogonal routing clears them around the stack.
8397
+ *
8398
+ * Uses bounding-box gaps (not center dx/dy): a large horizontal offset must not
8399
+ * flip bottom→left while the source is still below the target — that reintroduces
8400
+ * the left-edge contour jog when the user drags the node leftward.
8401
+ *
8402
+ * - Works for portId and outlineParam (OEF / attach-to-outline).
8403
+ * - Does NOT move an endpoint that is already on a chosen side — otherwise
8404
+ * dragging a locked/outline anchor snaps back on every updateAll.
8405
+ * - Keeps intentional source left/right exits for nest wrap around a parent.
8406
+ */
8407
+ preferFacingSidePorts(edge, fromNode, toNode) {
8408
+ if (!edge.lockAnchors) return;
8409
+ if (!isNodeEdgeEndpoint(edge.from) || !isNodeEdgeEndpoint(edge.to)) return;
8410
+ const fromB = fromNode.getBounds();
8411
+ const toB = toNode.getBounds();
8412
+ const fromBelow = fromB.y - (toB.y + toB.height);
8413
+ const fromAbove = toB.y - (fromB.y + fromB.height);
8414
+ const fromRight = fromB.x - (toB.x + toB.width);
8415
+ const fromLeft = toB.x - (fromB.x + fromB.width);
8416
+ const fc = fromNode.getCenter();
8417
+ const tc = toNode.getCenter();
8418
+ const curFrom = this.endpointSide(edge.from, fromNode);
8419
+ const curTo = this.endpointSide(edge.to, toNode);
8420
+ if (fromBelow > 0 || fromAbove > 0) {
8421
+ const fromSide = fromBelow > 0 ? "top" : "bottom";
8422
+ const toSide = fromBelow > 0 ? "bottom" : "top";
8423
+ const toOutlineLateral = (curTo === "left" || curTo === "right") && isNodeEdgeEndpoint(edge.to) && edge.to.outlineParam !== void 0;
8424
+ const toPortLateral = (curTo === "left" || curTo === "right") && !toOutlineLateral;
8425
+ if (!curFrom) this.setEndpointSidePort(edge, "from", fromNode, fromSide, tc);
8426
+ if (!curTo || toPortLateral) this.setEndpointSidePort(edge, "to", toNode, toSide, fc);
8427
+ return;
8428
+ }
8429
+ if (fromLeft > 0 || fromRight > 0) {
8430
+ const fromSide = fromRight > 0 ? "left" : "right";
8431
+ const toSide = fromRight > 0 ? "right" : "left";
8432
+ const toOutlineVertical = (curTo === "top" || curTo === "bottom") && isNodeEdgeEndpoint(edge.to) && edge.to.outlineParam !== void 0;
8433
+ const toPortVertical = (curTo === "top" || curTo === "bottom") && !toOutlineVertical;
8434
+ if (!curFrom) this.setEndpointSidePort(edge, "from", fromNode, fromSide, tc);
8435
+ if (!curTo || toPortVertical) this.setEndpointSidePort(edge, "to", toNode, toSide, fc);
8436
+ }
8437
+ }
8438
+ portSide(portId) {
8439
+ if (!portId?.startsWith("anchor:")) return void 0;
8440
+ const side = portId.slice(ANCHOR_PORT_PREFIX.length).split(":")[0];
8441
+ if (side === "top" || side === "right" || side === "bottom" || side === "left") return side;
8442
+ }
8443
+ endpointSide(endpoint, node) {
8444
+ if (!isNodeEdgeEndpoint(endpoint)) return void 0;
8445
+ const fromPort = this.portSide(endpoint.portId);
8446
+ if (fromPort) return fromPort;
8447
+ if (endpoint.outlineParam === void 0) return void 0;
8448
+ return getDirectionFromOutlineParam(endpoint.outlineParam, node.getBounds());
8449
+ }
8450
+ nearestAnchorOnSide(node, side, toward) {
8451
+ const anchors = node.getAnchors().filter((a) => a.id.startsWith(`${side}:`));
8452
+ if (anchors.length === 0) return null;
8453
+ let best = anchors[0];
8454
+ let minDist = Infinity;
8455
+ for (const anchor of anchors) {
8456
+ const dist = (anchor.point.x - toward.x) ** 2 + (anchor.point.y - toward.y) ** 2;
8457
+ if (dist < minDist) {
8458
+ minDist = dist;
8459
+ best = anchor;
8460
+ }
8461
+ }
8462
+ return best;
8463
+ }
8464
+ setEndpointSidePort(edge, end, node, side, toward) {
8465
+ const anchor = this.nearestAnchorOnSide(node, side, toward);
8466
+ if (!anchor) return;
8467
+ const next = {
8468
+ ...end === "from" ? edge.from : edge.to,
8469
+ portId: `${ANCHOR_PORT_PREFIX}${anchor.id}`,
8470
+ outlineParam: void 0
8471
+ };
8472
+ if (end === "from") edge.from = next;
8473
+ else edge.to = next;
8474
+ }
8475
+ /**
7709
8476
  * Resolve a node endpoint:
7710
8477
  * - lockAnchors + portId → fixed side port (wins over leftover outlineParam)
7711
8478
  * - outlineParam → fixed point on outline (attach-to-outline)
@@ -7720,10 +8487,13 @@ var EdgeEndpointUpdater = class {
7720
8487
  direction: anchorId.split(":")[0]
7721
8488
  };
7722
8489
  }
7723
- if (isNodeEdgeEndpoint(endpoint) && endpoint.outlineParam !== void 0) return {
7724
- point: node.getConnectionPointAtOutlineParam(endpoint.outlineParam),
7725
- direction: getDirectionFromOutlineParam(endpoint.outlineParam)
7726
- };
8490
+ if (isNodeEdgeEndpoint(endpoint) && endpoint.outlineParam !== void 0) {
8491
+ const bounds = node.getBounds();
8492
+ return {
8493
+ point: node.getConnectionPointAtOutlineParam(endpoint.outlineParam),
8494
+ direction: getDirectionFromOutlineParam(endpoint.outlineParam, bounds)
8495
+ };
8496
+ }
7727
8497
  if (toward) return this.resolveFloatingEndpoint(node, toward);
7728
8498
  return { point: node.getCenter() };
7729
8499
  }
@@ -8744,12 +9514,13 @@ var DiagramRenderer = class extends EventEmitter {
8744
9514
  }
8745
9515
  getNodeObstacles() {
8746
9516
  if (this._nodeObstaclesCache !== null) return this._nodeObstaclesCache;
9517
+ const pad = 4;
8747
9518
  this._nodeObstaclesCache = Array.from(this._nodes.values()).map((node) => ({
8748
9519
  id: node.id,
8749
- x: node.x - 8,
8750
- y: node.y - 8,
8751
- width: node.width + 16,
8752
- height: node.height + 16,
9520
+ x: node.x - pad,
9521
+ y: node.y - pad,
9522
+ width: node.width + pad * 2,
9523
+ height: node.height + pad * 2,
8753
9524
  role: "other"
8754
9525
  }));
8755
9526
  return this._nodeObstaclesCache;