elkrb 1.0.2 → 2.0.0

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.
Files changed (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.adoc +364 -0
  3. data/LICENSE +25 -0
  4. data/README.adoc +695 -557
  5. data/exe/elkrb +36 -2
  6. data/lib/elkrb/atomic_destination.rb +53 -0
  7. data/lib/elkrb/best_effort_write.rb +49 -0
  8. data/lib/elkrb/cli/layout_flag_options.rb +54 -0
  9. data/lib/elkrb/cli.rb +155 -82
  10. data/lib/elkrb/command_resolver.rb +186 -0
  11. data/lib/elkrb/commands/batch_command.rb +28 -5
  12. data/lib/elkrb/commands/convert_command.rb +22 -52
  13. data/lib/elkrb/commands/diagram_command.rb +234 -102
  14. data/lib/elkrb/commands/options_command.rb +87 -0
  15. data/lib/elkrb/commands/render_command.rb +6 -1
  16. data/lib/elkrb/commands/validate_command.rb +146 -91
  17. data/lib/elkrb/errors.rb +20 -1
  18. data/lib/elkrb/format_sniffer.rb +362 -0
  19. data/lib/elkrb/geometry/bezier.rb +14 -4
  20. data/lib/elkrb/geometry/point.rb +5 -6
  21. data/lib/elkrb/geometry/rectangle.rb +2 -2
  22. data/lib/elkrb/graph/deep_stringify_keys.rb +30 -0
  23. data/lib/elkrb/graph/edge.rb +89 -3
  24. data/lib/elkrb/graph/graph.rb +43 -15
  25. data/lib/elkrb/graph/label.rb +16 -2
  26. data/lib/elkrb/graph/node.rb +13 -2
  27. data/lib/elkrb/graph/node_constraints.rb +139 -8
  28. data/lib/elkrb/graph/normalize_option_keys.rb +39 -0
  29. data/lib/elkrb/graph/port.rb +29 -21
  30. data/lib/elkrb/graph/read_only_mapping.rb +21 -0
  31. data/lib/elkrb/graphviz_wrapper.rb +152 -44
  32. data/lib/elkrb/layout/algorithm_registry.rb +65 -8
  33. data/lib/elkrb/layout/algorithms/base_algorithm.rb +74 -74
  34. data/lib/elkrb/layout/algorithms/box.rb +47 -20
  35. data/lib/elkrb/layout/algorithms/disco.rb +150 -61
  36. data/lib/elkrb/layout/algorithms/fixed.rb +70 -10
  37. data/lib/elkrb/layout/algorithms/force.rb +145 -86
  38. data/lib/elkrb/layout/algorithms/layered/bk/aligner.rb +104 -0
  39. data/lib/elkrb/layout/algorithms/layered/bk/compactor.rb +87 -0
  40. data/lib/elkrb/layout/algorithms/layered/bk/view.rb +103 -0
  41. data/lib/elkrb/layout/algorithms/layered/bk_node_placer.rb +170 -0
  42. data/lib/elkrb/layout/algorithms/layered/components.rb +170 -0
  43. data/lib/elkrb/layout/algorithms/layered/crossing_minimizer.rb +306 -0
  44. data/lib/elkrb/layout/algorithms/layered/cycle_breaker.rb +118 -49
  45. data/lib/elkrb/layout/algorithms/layered/discovery_order.rb +75 -0
  46. data/lib/elkrb/layout/algorithms/layered/dummy_slot.rb +31 -0
  47. data/lib/elkrb/layout/algorithms/layered/layer_assigner.rb +188 -48
  48. data/lib/elkrb/layout/algorithms/layered/layer_constraints.rb +82 -0
  49. data/lib/elkrb/layout/algorithms/layered/layer_sweep.rb +195 -0
  50. data/lib/elkrb/layout/algorithms/layered/node_placer.rb +214 -36
  51. data/lib/elkrb/layout/algorithms/layered/orthogonal/gap_solver.rb +219 -0
  52. data/lib/elkrb/layout/algorithms/layered/orthogonal/hyper_edge_segment.rb +152 -0
  53. data/lib/elkrb/layout/algorithms/layered/orthogonal/junction_points.rb +58 -0
  54. data/lib/elkrb/layout/algorithms/layered/orthogonal/segment_order.rb +134 -0
  55. data/lib/elkrb/layout/algorithms/layered/orthogonal_router.rb +253 -0
  56. data/lib/elkrb/layout/algorithms/layered/port_order.rb +188 -0
  57. data/lib/elkrb/layout/algorithms/layered/port_spread.rb +42 -0
  58. data/lib/elkrb/layout/algorithms/layered.rb +548 -13
  59. data/lib/elkrb/layout/algorithms/libavoid.rb +951 -187
  60. data/lib/elkrb/layout/algorithms/mrtree.rb +585 -65
  61. data/lib/elkrb/layout/algorithms/radial.rb +188 -29
  62. data/lib/elkrb/layout/algorithms/random.rb +131 -17
  63. data/lib/elkrb/layout/algorithms/rectpacking.rb +28 -40
  64. data/lib/elkrb/layout/algorithms/spore_compaction.rb +57 -100
  65. data/lib/elkrb/layout/algorithms/spore_overlap.rb +78 -84
  66. data/lib/elkrb/layout/algorithms/stress.rb +391 -84
  67. data/lib/elkrb/layout/algorithms/topdown_packing.rb +95 -31
  68. data/lib/elkrb/layout/algorithms/vertiflex.rb +12 -47
  69. data/lib/elkrb/layout/constraints/alignment_constraint.rb +49 -12
  70. data/lib/elkrb/layout/constraints/base_constraint.rb +3 -15
  71. data/lib/elkrb/layout/constraints/constraint_processor.rb +9 -23
  72. data/lib/elkrb/layout/constraints/fixed_position_constraint.rb +22 -13
  73. data/lib/elkrb/layout/constraints/layer_constraint.rb +10 -33
  74. data/lib/elkrb/layout/constraints/relative_position_constraint.rb +94 -50
  75. data/lib/elkrb/layout/edge_router.rb +533 -337
  76. data/lib/elkrb/layout/hierarchical_processor.rb +19 -276
  77. data/lib/elkrb/layout/java_random.rb +59 -0
  78. data/lib/elkrb/layout/label_placer.rb +165 -157
  79. data/lib/elkrb/layout/layout_engine.rb +46 -23
  80. data/lib/elkrb/layout/node_index.rb +149 -0
  81. data/lib/elkrb/layout/polyomino/component_compactor.rb +138 -0
  82. data/lib/elkrb/layout/polyomino/footprint.rb +94 -0
  83. data/lib/elkrb/layout/polyomino/grid.rb +106 -0
  84. data/lib/elkrb/layout/polyomino/packer.rb +95 -0
  85. data/lib/elkrb/layout/polyomino/piece.rb +52 -0
  86. data/lib/elkrb/layout/port_constraint_processor.rb +131 -44
  87. data/lib/elkrb/layout/spore/body.rb +65 -0
  88. data/lib/elkrb/layout/spore/depth_first_compaction.rb +127 -0
  89. data/lib/elkrb/layout/spore/edge_cost.rb +69 -0
  90. data/lib/elkrb/layout/spore/gap.rb +76 -0
  91. data/lib/elkrb/layout/spore/geometry.rb +104 -0
  92. data/lib/elkrb/layout/spore/overlap_sweep.rb +90 -0
  93. data/lib/elkrb/layout/spore/placement.rb +57 -0
  94. data/lib/elkrb/layout/spore/rays.rb +119 -0
  95. data/lib/elkrb/layout/spore/spanning_tree.rb +53 -0
  96. data/lib/elkrb/layout/spore/triangulation.rb +159 -0
  97. data/lib/elkrb/layout_flags.rb +69 -0
  98. data/lib/elkrb/options/decimal.rb +50 -0
  99. data/lib/elkrb/options/element_options.rb +43 -0
  100. data/lib/elkrb/options/elk_padding.rb +20 -14
  101. data/lib/elkrb/options/k_vector.rb +6 -7
  102. data/lib/elkrb/options/k_vector_chain.rb +12 -20
  103. data/lib/elkrb/options/option_map.rb +85 -0
  104. data/lib/elkrb/options/registry.rb +335 -0
  105. data/lib/elkrb/options/resolver.rb +151 -0
  106. data/lib/elkrb/options/spellings.rb +62 -0
  107. data/lib/elkrb/options/unhonoured_report.rb +194 -0
  108. data/lib/elkrb/parsers/elkt/lexer.rb +309 -0
  109. data/lib/elkrb/parsers/elkt/parser.rb +424 -0
  110. data/lib/elkrb/parsers/elkt/resolver.rb +142 -0
  111. data/lib/elkrb/parsers/elkt/token.rb +40 -0
  112. data/lib/elkrb/parsers/elkt_parser.rb +32 -230
  113. data/lib/elkrb/serializers/dot_serializer.rb +342 -54
  114. data/lib/elkrb/serializers/elkt_serializer.rb +386 -155
  115. data/lib/elkrb/version.rb +1 -1
  116. data/lib/elkrb.rb +60 -61
  117. metadata +71 -49
  118. data/.rspec +0 -3
  119. data/.rubocop.yml +0 -11
  120. data/Gemfile +0 -13
  121. data/Rakefile +0 -64
  122. data/benchmarks/README.md +0 -172
  123. data/benchmarks/elkjs_benchmark.js +0 -140
  124. data/benchmarks/elkrb_benchmark.rb +0 -145
  125. data/benchmarks/fixtures/graphs.json +0 -10777
  126. data/benchmarks/generate_report.rb +0 -241
  127. data/benchmarks/generate_test_graphs.rb +0 -154
  128. data/benchmarks/results/elkrb_results.json +0 -280
  129. data/benchmarks/results/elkrb_summary.json +0 -285
  130. data/elkrb.gemspec +0 -39
  131. data/examples/dot_export_demo.rb +0 -133
  132. data/examples/hierarchical_graph.rb +0 -19
  133. data/examples/layout_constraints_demo.rb +0 -272
  134. data/examples/port_constraints_demo.rb +0 -291
  135. data/examples/self_loop_demo.rb +0 -391
  136. data/examples/simple_graph.rb +0 -50
  137. data/examples/spline_routing_demo.rb +0 -235
  138. data/lib/elkrb/graph/layout_options.rb +0 -247
  139. data/sig/elkrb/constraints.rbs +0 -114
  140. data/sig/elkrb/geometry.rbs +0 -61
  141. data/sig/elkrb/graph.rbs +0 -112
  142. data/sig/elkrb/layout.rbs +0 -107
  143. data/sig/elkrb/options.rbs +0 -81
  144. data/sig/elkrb.rbs +0 -32
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base_algorithm"
4
+ require_relative "../algorithm_registry"
4
5
  require_relative "../../geometry/point"
5
6
  require_relative "../../geometry/rectangle"
6
7
 
@@ -15,16 +16,21 @@ module Elkrb
15
16
  #
16
17
  # Features:
17
18
  # - Orthogonal (90-degree) routing
18
- # - Obstacle avoidance
19
+ # - Bounded A* obstacle avoidance, scoped per edge, with an observable
20
+ # fallback when no path is found within the search limit
19
21
  # - Bend minimization
20
- # - Configurable routing padding
22
+ # - Configurable routing padding, grid step, and search limit
21
23
  #
22
24
  # Options:
23
25
  # - libavoid.routingPadding: Padding around obstacles (default: 10)
26
+ # - libavoid.stepSize: Grid step for the A* search (default: 10,
27
+ # independent of routingPadding so a padding of 0 cannot collapse it)
28
+ # - libavoid.maxExpansions: Search cap before falling back (default: 2000)
24
29
  # - libavoid.segmentPenalty: Penalty for additional segments (default: 1.0)
25
30
  # - libavoid.bendPenalty: Penalty for bends (default: 2.0)
26
31
  class Libavoid < BaseAlgorithm
27
- # Priority queue node for A* algorithm
32
+ # Released public API (1.0.2) that the search no longer uses; keep it
33
+ # unchanged and do not build new search code on it. See SearchNode.
28
34
  class PathNode
29
35
  attr_accessor :point, :parent, :g_score, :f_score, :direction
30
36
 
@@ -50,179 +56,1001 @@ f_score = Float::INFINITY, direction = nil)
50
56
  end
51
57
  end
52
58
 
59
+ # An immutable A* search record: the point, its parent (for path
60
+ # reconstruction), the cost so far, the estimated total cost, and the
61
+ # direction of travel used to reach it (for the bend penalty). The
62
+ # search's hash keys are `[grid_key, direction]` pairs, or GOAL_KEY for
63
+ # the goal -- never a SearchNode or a point.
64
+ SearchNode = Data.define(:point, :parent, :g_score, :f_score, :direction) do
65
+ # This node extended by one straight leg to `target`: the same node
66
+ # when the leg has no length, otherwise a SearchNode carrying the cost
67
+ # of the leg, a segment penalty per grid step it spans (so a long
68
+ # final hop costs what the same distance in grid steps does), and a
69
+ # bend penalty on a change of direction.
70
+ def extend_to(target, heading, plan)
71
+ return self if SearchPoint.same?(point, target)
72
+
73
+ length = SearchPoint.distance(point, target)
74
+ cost = g_score + length + plan.segment_cost(length) + bend_cost(heading, plan)
75
+ SearchNode.new(point: target, parent: self, g_score: cost,
76
+ f_score: cost + plan.estimate(target), direction: heading)
77
+ end
78
+
79
+ # The bend penalty for continuing in direction `heading`: none for
80
+ # the first leg or a straight continuation.
81
+ def bend_cost(heading, plan)
82
+ turned = direction && direction != heading
83
+ turned ? plan.bend : 0
84
+ end
85
+
86
+ # This node extended to `target` by two straight legs that meet at
87
+ # `corner`.
88
+ def extend_through(corner, target, plan)
89
+ at_corner = extend_to(corner, heading_to(corner), plan)
90
+ at_corner.extend_to(target, at_corner.heading_to(target), plan)
91
+ end
92
+
93
+ # The direction of a straight leg from this node to `target`.
94
+ def heading_to(target)
95
+ point.y == target.y ? :horizontal : :vertical
96
+ end
97
+ end
98
+
99
+ # Largest value any numeric libavoid option is read as: far beyond any
100
+ # real coordinate scale, yet small enough that the sums, products and
101
+ # ratios of options and coordinates in the search stay finite.
102
+ OPTION_CEILING = 1e9
103
+
104
+ # The open-set key of the goal, which is not on the step grid.
105
+ GOAL_KEY = :goal
106
+
107
+ # What one search needs to cost a leg and to estimate the rest: the
108
+ # goal and the three numeric options, read once per search instead of
109
+ # once per step.
110
+ SearchPlan = Data.define(:goal, :step, :segment, :bend) do
111
+ # The cheapest cost of one unit of travel: the distance itself plus
112
+ # its share of the segment penalty. Scaling the distance heuristic
113
+ # by it keeps the heuristic a lower bound while letting it see the
114
+ # penalty, so the search heads for the goal instead of flooding.
115
+ def per_unit
116
+ step.positive? ? 1.0 + (segment / step) : 1.0
117
+ end
118
+
119
+ # A lower bound on the cost left from `point`: the Manhattan
120
+ # distance to the goal at the cheapest cost per unit.
121
+ def estimate(point)
122
+ ((point.x - goal.x).abs + (point.y - goal.y).abs) * per_unit
123
+ end
124
+
125
+ # The segment penalty for a leg of `length`: one per grid step.
126
+ def segment_cost(length)
127
+ segment * (step.positive? ? length / step : 1)
128
+ end
129
+ end
130
+
131
+ # A plain x/y pair for points inside the search hot path. Far cheaper
132
+ # to build than Geometry::Point, a lutaml-model object. Every search
133
+ # helper reads only .x and .y, so the two are interchangeable there;
134
+ # only points that leave the search become Geometry::Point.
135
+ SearchPoint = Data.define(:x, :y) do
136
+ # True when the segment between two points (of either kind) is
137
+ # horizontal or vertical. The tolerance only absorbs float drift
138
+ # from summing grid steps; it is far below any visible offset.
139
+ def self.aligned?(from_pt, to_pt)
140
+ (from_pt.x - to_pt.x).abs < 1e-6 || (from_pt.y - to_pt.y).abs < 1e-6
141
+ end
142
+
143
+ # A search point is `origin` plus whole grid steps, so its step
144
+ # counts identify it exactly, for any finite positive step size.
145
+ # Rounding the coordinates instead merges distinct points once a
146
+ # step is small. Zero, negative or non-finite steps (a caller
147
+ # error, not a real grid) fall back to the exact offset instead of
148
+ # dividing -- `(x / Float::INFINITY).round` raises FloatDomainError.
149
+ def self.grid_key(point, origin, step)
150
+ offset_x = point.x - origin.x
151
+ offset_y = point.y - origin.y
152
+ return [offset_x, offset_y] unless step.finite? && step.positive?
153
+
154
+ [(offset_x / step).round, (offset_y / step).round]
155
+ end
156
+
157
+ # True when both points hold the same coordinates.
158
+ def self.same?(first, second)
159
+ first.x == second.x && first.y == second.y
160
+ end
161
+
162
+ # Which side of the line from `origin` to `second` the point `first`
163
+ # lies on: positive on one side, negative on the other, zero on the
164
+ # line. Exact, no tolerance.
165
+ def self.cross(origin, first, second)
166
+ origin_x = origin.x
167
+ origin_y = origin.y
168
+ ((first.x - origin_x) * (second.y - origin_y)) -
169
+ ((second.x - origin_x) * (first.y - origin_y))
170
+ end
171
+
172
+ # The straight-line distance between two points (of either kind).
173
+ def self.distance(from_pt, to_pt)
174
+ dx = to_pt.x - from_pt.x
175
+ dy = to_pt.y - from_pt.y
176
+ Math.sqrt((dx * dx) + (dy * dy))
177
+ end
178
+ end
179
+
180
+ # A straight segment, for the intersection test behind the collision
181
+ # check.
182
+ Segment = Data.define(:from, :to) do
183
+ # True when the two segments cross, touch, or overlap. The strict
184
+ # straddle test alone misses a collinear touch or overlap -- a
185
+ # segment that runs exactly along one of the other's endpoints
186
+ # scores every #side_of as zero and would otherwise be reported
187
+ # clear, letting a search step wide enough to span an obstacle's
188
+ # full width cross its boundary undetected.
189
+ def intersects?(other)
190
+ (straddles?(other) && other.straddles?(self)) ||
191
+ touches?(other) || other.touches?(self)
192
+ end
193
+
194
+ # True when the endpoints of `other` lie strictly on opposite sides
195
+ # of the line through this segment.
196
+ def straddles?(other)
197
+ first = side_of(other.from)
198
+ second = side_of(other.to)
199
+ (first.positive? && second.negative?) || (first.negative? && second.positive?)
200
+ end
201
+
202
+ # True when an endpoint of `other` lies on this segment.
203
+ def touches?(other)
204
+ endpoint_on?(other.from) || endpoint_on?(other.to)
205
+ end
206
+
207
+ # True when `point` is exactly on the line through this segment.
208
+ def on_line?(point)
209
+ side_of(point).zero?
210
+ end
211
+
212
+ # Which side of the line through this segment `point` lies on:
213
+ # positive on one side, negative on the other, zero on the line.
214
+ def side_of(point)
215
+ SearchPoint.cross(from, point, to)
216
+ end
217
+
218
+ # True when `point` falls within the segment's bounding box. Only
219
+ # meaningful for a point already on its line.
220
+ def covers?(point)
221
+ point.x.between?(*[from.x, to.x].minmax) &&
222
+ point.y.between?(*[from.y, to.y].minmax)
223
+ end
224
+
225
+ private
226
+
227
+ def endpoint_on?(point)
228
+ on_line?(point) && covers?(point)
229
+ end
230
+ end
231
+
232
+ # A rectangle's real footprint, independent of the sign of its
233
+ # width/height. Elkrb::Geometry::Rectangle does not normalize on
234
+ # construction (a node authored with a negative width/height keeps
235
+ # it), so `rect.x + rect.width` can be LESS than `rect.x`; reading it
236
+ # as the right edge directly made the point and segment checks
237
+ # silently miss the obstacle's true footprint.
238
+ Bounds = Data.define(:left, :right, :top, :bottom) do
239
+ def self.of(rect)
240
+ rect.is_a?(self) ? rect : from_rectangle(rect)
241
+ end
242
+
243
+ def self.from_rectangle(rect)
244
+ origin_x = rect.x
245
+ origin_y = rect.y
246
+ left, right = [origin_x, origin_x + rect.width].minmax
247
+ top, bottom = [origin_y, origin_y + rect.height].minmax
248
+ new(left: left, right: right, top: top, bottom: bottom)
249
+ end
250
+
251
+ # `rect` without its padding when it holds one of the `anchors`: the
252
+ # padding is a preferred clearance, and a neighbour closer than twice
253
+ # the padding would otherwise swallow the edge's own departure or
254
+ # arrival point and leave it no way out.
255
+ def self.relax(rect, padding, anchors)
256
+ bounds = of(rect)
257
+ return rect unless anchors.any? { |anchor| bounds.include?(anchor) }
258
+
259
+ bounds.inset(padding).to_rectangle
260
+ end
261
+
262
+ # Inclusive of the boundary.
263
+ def include?(point)
264
+ point.x.between?(left, right) && point.y.between?(top, bottom)
265
+ end
266
+
267
+ # True when both ends of a segment lie past the same edge, so the
268
+ # segment cannot touch the footprint. A NaN coordinate is never
269
+ # beyond an edge, which leaves it to the full intersection test.
270
+ def beyond?(from, to)
271
+ [[from.x, to.x, left, right], [from.y, to.y, top, bottom]].any? do |one, other, low, high|
272
+ (one < low && other < low) || (one > high && other > high)
273
+ end
274
+ end
275
+
276
+ # True when the segment from `from` to `to` crosses or touches a
277
+ # side of the footprint.
278
+ def crossed_by?(from, to)
279
+ return false if beyond?(from, to)
280
+
281
+ segment = Segment.new(from: from, to: to)
282
+ sides.any? { |side| segment.intersects?(side) }
283
+ end
284
+
285
+ # The four sides, as segments.
286
+ def sides
287
+ corners = [[left, top], [right, top], [right, bottom], [left, bottom]].map do |corner_x, corner_y|
288
+ SearchPoint.new(x: corner_x, y: corner_y)
289
+ end
290
+ corners.zip(corners.rotate).map { |from, to| Segment.new(from: from, to: to) }
291
+ end
292
+
293
+ # The footprint shrunk by `amount` on every side.
294
+ def inset(amount)
295
+ with(left: left + amount, right: right - amount,
296
+ top: top + amount, bottom: bottom - amount)
297
+ end
298
+
299
+ def to_rectangle
300
+ Geometry::Rectangle.new(left, top, right - left, bottom - top)
301
+ end
302
+ end
303
+
304
+ # The two different nodes of one level an edge joins, directly or
305
+ # through their ports. `resolve` is nil for a self-loop (including a
306
+ # port back to its own node) or an edge that leaves the level.
307
+ EdgeEnds = Data.define(:source, :target) do
308
+ def self.resolve(edge, node_map)
309
+ source, target = ids_to_nodes(edge, node_map)
310
+ new(source: source, target: target) if source && target && !source.equal?(target)
311
+ end
312
+
313
+ # True for a self-loop by OWNER identity, not raw endpoint-id
314
+ # equality -- a port back to its own node is one too, even though
315
+ # its source and target ids differ.
316
+ def self.same_owner?(edge, node_map)
317
+ source, target = ids_to_nodes(edge, node_map)
318
+ !!(source && target && source.equal?(target))
319
+ end
320
+
321
+ def self.ids_to_nodes(edge, node_map)
322
+ [edge.sources&.first, edge.targets&.first].map { |id| id && node_map.node(id) }
323
+ end
324
+ end
325
+
326
+ # One edge's search area. The search never leaves it, and only
327
+ # obstacles overlapping it are checked, so a short edge costs the same
328
+ # however large the rest of the graph is.
329
+ SearchBox = Data.define(:min_x, :min_y, :max_x, :max_y) do
330
+ def self.around(start_point, end_point, margin)
331
+ min_x, max_x = [start_point.x, end_point.x].minmax
332
+ min_y, max_y = [start_point.y, end_point.y].minmax
333
+ new(min_x: min_x - margin, min_y: min_y - margin,
334
+ max_x: max_x + margin, max_y: max_y + margin)
335
+ end
336
+
337
+ def contains?(point)
338
+ point.x.between?(min_x, max_x) && point.y.between?(min_y, max_y)
339
+ end
340
+
341
+ def overlaps?(rect)
342
+ rect.right >= min_x && rect.left <= max_x &&
343
+ rect.bottom >= min_y && rect.top <= max_y
344
+ end
345
+ end
346
+
347
+ # A node's unpadded rectangle, as its center and half sizes.
348
+ NodeBox = Data.define(:center_x, :center_y, :half_width, :half_height) do
349
+ # center_x/y use the SIGNED width/height, same as build_obstacle_map's
350
+ # `node.x + node.width`: (left + right) / 2 equals `x + width / 2`
351
+ # whichever endpoint is smaller. half_width/half_height are stored
352
+ # as magnitudes -- every caller (border_toward's division,
353
+ # #overlaps?) treats them as a positive half-extent, which is also
354
+ # what build_obstacle_map's own `.minmax` normalizes a negative
355
+ # width or height down to, so the box matches the obstacle map's
356
+ # rectangle for the same node.
357
+ def self.of(node)
358
+ width = node.width || 0.0
359
+ height = node.height || 0.0
360
+ new(center_x: (node.x || 0.0) + (width / 2.0),
361
+ center_y: (node.y || 0.0) + (height / 2.0),
362
+ half_width: width.abs / 2.0, half_height: height.abs / 2.0)
363
+ end
364
+
365
+ # Where the ray from the center toward `target` crosses the
366
+ # boundary; the center itself when the two coincide -- or when
367
+ # `scale` is zero for any other reason (a zero half-dimension on
368
+ # the binding axis), since `0.0 * offset` should stay the center
369
+ # even for an infinite offset. Ruby's `0.0 * Float::INFINITY` is
370
+ # NaN, not 0.0, so the zero case is special-cased here rather than
371
+ # left to the multiplication.
372
+ def border_toward(target)
373
+ offset_x = target.x - center_x
374
+ offset_y = target.y - center_y
375
+ scale = scale_to_border(offset_x, offset_y)
376
+ return Geometry::Point.new(x: center_x, y: center_y) if scale.zero?
377
+
378
+ Geometry::Point.new(x: center_x + (scale * offset_x),
379
+ y: center_y + (scale * offset_y))
380
+ end
381
+
382
+ # The side named `before` or `after` that `offset` points to.
383
+ def self.facing(offset, before, after)
384
+ offset.negative? ? before : after
385
+ end
386
+
387
+ # Which of the four sides `anchor` is closest to, as a fraction of
388
+ # the corresponding half extent -- the axis nearer its own edge
389
+ # wins, so a near-corner point still resolves to a single side
390
+ # rather than a diagonal. An exact tie goes to the horizontal axis.
391
+ def side_nearest(anchor)
392
+ offset_x = anchor.x - center_x
393
+ offset_y = anchor.y - center_y
394
+ if x_fraction(offset_x) >= y_fraction(offset_y)
395
+ NodeBox.facing(offset_x, "WEST", "EAST")
396
+ else
397
+ NodeBox.facing(offset_y, "NORTH", "SOUTH")
398
+ end
399
+ end
400
+
401
+ private
402
+
403
+ # How far `offset` is along a half extent; infinite when the extent
404
+ # is zero, so that axis always wins.
405
+ def x_fraction(offset)
406
+ half_width.zero? ? Float::INFINITY : offset.abs / half_width
407
+ end
408
+
409
+ def y_fraction(offset)
410
+ half_height.zero? ? Float::INFINITY : offset.abs / half_height
411
+ end
412
+
413
+ # The shortest fraction of the offset that reaches a side; 0 for a
414
+ # zero offset.
415
+ def scale_to_border(offset_x, offset_y)
416
+ x_scale = half_width / offset_x.abs unless offset_x.zero?
417
+ y_scale = half_height / offset_y.abs unless offset_y.zero?
418
+ [x_scale, y_scale].compact.min || 0.0
419
+ end
420
+ end
421
+
422
+ # Where an edge meets a node: the point on its border (or a port) and
423
+ # the side of the node that point faces.
424
+ Exit = Data.define(:anchor, :side) do
425
+ # The point `distance` straight out from the anchor on its side
426
+ # (never diagonally -- every segment this algorithm draws is
427
+ # orthogonal). Any side other than WEST, NORTH or SOUTH is EAST.
428
+ def clearance_point(distance)
429
+ offset = %w[WEST NORTH].include?(side) ? -distance : distance
430
+ shift_x, shift_y = %w[NORTH SOUTH].include?(side) ? [0.0, offset] : [offset, 0.0]
431
+ Geometry::Point.new(x: anchor.x + shift_x, y: anchor.y + shift_y)
432
+ end
433
+ end
434
+
435
+ # Builds the keys of #routing_diagnostics. ELK ids are arbitrary JSON
436
+ # strings -- nothing in the format excludes "/", so joining hierarchy
437
+ # segments with a bare "/" is not collision-safe: a top-level edge
438
+ # literally named "p/e" and a nested edge "e" under node "p" both
439
+ # produce the unescaped key "p/e". Escaping any "\" or "/" already
440
+ # inside a segment before it is joined means the separator "/" in a
441
+ # built key can only ever be a real hierarchy boundary.
442
+ module DiagnosticsKey
443
+ # The key of the edge `id` under the hierarchy `prefix` ("" at the
444
+ # top level, otherwise the escaped ids of the enclosing nodes, each
445
+ # followed by "/").
446
+ def self.edge(prefix, id)
447
+ "#{prefix}#{segment(id)}"
448
+ end
449
+
450
+ def self.segment(id)
451
+ # Block form: a String replacement to gsub interprets leading
452
+ # backslashes as backreferences (\1, \&, ...), so a plain
453
+ # replacement string can't produce a literal "\\" reliably. A
454
+ # block's return value is inserted as-is.
455
+ id.to_s.gsub("\\") { "\\\\" }.gsub("/") { "\\/" }
456
+ end
457
+ end
458
+
459
+ # A small binary min-heap over [f_score, sequence, grid_key] tuples:
460
+ # the A* open set. Ties break by `sequence` (push order).
461
+ #
462
+ # Uses lazy deletion, not decrease-key -- #find_path pushes a new
463
+ # tuple on every improvement and leaves the stale one in the heap.
464
+ # Each push for a key carries an f_score no larger than the last,
465
+ # so the best entry for a key always pops before its stale
466
+ # predecessors: #find_path only needs to check whether a popped key
467
+ # is already closed, never compare f_scores on pop.
468
+ class OpenSetHeap
469
+ def initialize
470
+ @entries = []
471
+ end
472
+
473
+ def push(f_score, sequence, key)
474
+ @entries << [f_score, sequence, key]
475
+ sift_up(@entries.size - 1)
476
+ end
477
+
478
+ def pop
479
+ last = @entries.pop
480
+ return last if @entries.empty?
481
+
482
+ replace_root(last)
483
+ end
484
+
485
+ def empty?
486
+ @entries.empty?
487
+ end
488
+
489
+ private
490
+
491
+ # Puts `entry` at the root in place of the smallest entry, which it
492
+ # returns.
493
+ def replace_root(entry)
494
+ top = @entries.first
495
+ @entries[0] = entry
496
+ sift_down(0)
497
+ top
498
+ end
499
+
500
+ def sift_up(index)
501
+ while index.positive?
502
+ parent = (index - 1) / 2
503
+ break unless less?(index, parent)
504
+
505
+ swap(index, parent)
506
+ index = parent
507
+ end
508
+ end
509
+
510
+ def sift_down(index)
511
+ loop do
512
+ smallest = smallest_of(index)
513
+ break if smallest == index
514
+
515
+ swap(index, smallest)
516
+ index = smallest
517
+ end
518
+ end
519
+
520
+ # Whichever of `index` and its in-range children holds the smallest
521
+ # tuple.
522
+ def smallest_of(index)
523
+ left = (2 * index) + 1
524
+ children = [left, left + 1].select { |child| child < @entries.size }
525
+ [index, *children].min_by { |position| @entries[position] }
526
+ end
527
+
528
+ def less?(first, second)
529
+ (@entries[first] <=> @entries[second]).negative?
530
+ end
531
+
532
+ def swap(first, second)
533
+ @entries[first], @entries[second] = @entries[second], @entries[first]
534
+ end
535
+ end
536
+
537
+ # Tidies the route of one edge.
538
+ module Waypoints
539
+ # `points` without a repeated point or a point that sits on the
540
+ # straight line between its neighbours. A middle point that doubles
541
+ # back along the line (a stub that overshoots the other end's stub
542
+ # in a narrow gap) is dropped too; the shortened line stays inside
543
+ # the original.
544
+ def self.simplify(points)
545
+ points.each_with_object([]) do |point, kept|
546
+ kept.pop while doubles_back?(kept, point)
547
+ kept << point unless repeats?(kept, point)
548
+ end
549
+ end
550
+
551
+ # True when the last kept point lies on the line from the one
552
+ # before it to `point`.
553
+ def self.doubles_back?(kept, point)
554
+ kept.size >= 2 && Segment.new(from: kept[-2], to: point).on_line?(kept.last)
555
+ end
556
+
557
+ def self.repeats?(kept, point)
558
+ kept.any? && SearchPoint.same?(kept.last, point)
559
+ end
560
+ end
561
+
562
+ private_constant :OPTION_CEILING, :SearchNode, :SearchPoint, :EdgeEnds, :SearchBox, :NodeBox, :OpenSetHeap, :SearchPlan, :Segment,
563
+ :Bounds, :Exit, :DiagnosticsKey, :Waypoints
564
+
565
+ def initialize(options = {})
566
+ super
567
+ @routing_diagnostics = {}
568
+ end
569
+
570
+ # Routing outcome of the last #layout call, keyed by edge id (a nested
571
+ # edge's id is prefixed with its parent ids, joined by "/"; "\" and "/"
572
+ # inside an id are backslash-escaped): `:found`, `:capped` (search
573
+ # limit hit) or `:no_path` (no path, or a clearance stub blocked by
574
+ # another node). Either fallback is drawn as a direct line and is
575
+ # never overwritten by a later `:found` under the same key. Self-loops
576
+ # and edges leaving their level are not listed.
577
+ # @return [Hash{String => Symbol}] a frozen copy
578
+ # @example
579
+ # algorithm.layout(graph)
580
+ # algorithm.routing_diagnostics # => { "e1" => :found }
581
+ def routing_diagnostics
582
+ @routing_diagnostics.dup.freeze
583
+ end
584
+
585
+ # Only places and pads nodes. Obstacle routing happens later, in
586
+ # #apply_edge_routing, against every node's FINAL position and
587
+ # size -- never here, where a constraint or a compound node's
588
+ # parent-bound update could still move or resize a node afterward.
589
+ # Edges are left unrouted by this method.
590
+ #
591
+ # @param graph [Elkrb::Graph::Graph] the graph whose children are placed
592
+ # @param _options [Hash] unused
593
+ # @return [Elkrb::Graph::Graph] the same graph
594
+ # @example
595
+ # Elkrb::Layout::Algorithms::Libavoid.new.layout_flat(graph)
53
596
  def layout_flat(graph, _options = {})
54
597
  return graph if graph.children.nil? || graph.children.empty?
55
598
 
56
- # Position nodes if not already positioned
57
599
  position_nodes_if_needed(graph)
58
600
 
59
- # Build obstacle map from nodes
60
- obstacles = build_obstacle_map(graph.children)
61
-
62
- # Route each edge
63
- route_edges_with_obstacles(graph, obstacles)
64
-
65
- # Apply padding and set graph dimensions
601
+ # Pad now: padding shifts every node, and later steps (fixed
602
+ # position, parent bounds) only move or resize individual nodes,
603
+ # never re-run this bulk shift.
66
604
  apply_padding(graph)
67
605
 
68
606
  graph
69
607
  end
70
608
 
609
+ protected
610
+
611
+ # Routes every edge in the hierarchy around obstacles, at every
612
+ # level, against nodes' final positions and sizes -- so a fixed
613
+ # node's restored position and a compound node's grown bounds are
614
+ # both already settled by the time an edge is drawn to them.
615
+ def apply_edge_routing(graph)
616
+ @routing_diagnostics = {}
617
+ route_obstacle_edges(graph)
618
+ end
619
+
71
620
  private
72
621
 
622
+ # Route one level's own edges around its own nodes, then recurse
623
+ # into every hierarchical child so its inner edges are routed
624
+ # against that child's own nodes too. `diagnostics_prefix` qualifies
625
+ # `routing_diagnostics` keys by hierarchy level so a nested edge id
626
+ # that repeats a top-level (or sibling) edge id gets a distinct key
627
+ # instead of silently overwriting it -- empty at the top level, so
628
+ # the many existing top-level-only specs that key by a bare edge id
629
+ # are unaffected.
630
+ def route_obstacle_edges(graph, diagnostics_prefix = "")
631
+ children = graph.children
632
+ return unless children&.any?
633
+
634
+ if graph.edges&.any?
635
+ route_edges_with_obstacles(graph, build_obstacle_map(children), diagnostics_prefix)
636
+ end
637
+ recurse_into_hierarchical_children(children, diagnostics_prefix)
638
+ end
639
+
640
+ # A hierarchical child's own nested level gets the same treatment,
641
+ # against ITS nodes' final positions and sizes, keyed under its own
642
+ # id so a repeated edge id at a deeper level stays distinct too.
643
+ #
644
+ # Skip a node naming its OWN elk.algorithm: the hierarchical
645
+ # processor lays that node out with the algorithm it names
646
+ # (HierarchicalProcessor#child_layout_processor) through
647
+ # #layout_hierarchical, which routes no edges, so the node's inner
648
+ # edges keep no sections.
649
+ def recurse_into_hierarchical_children(children, diagnostics_prefix)
650
+ children.each do |node|
651
+ next unless node.hierarchical?
652
+
653
+ child_graph = create_child_graph(node)
654
+ next if different_algorithm?(child_graph)
655
+
656
+ route_obstacle_edges(child_graph, "#{diagnostics_prefix}#{DiagnosticsKey.segment(node.id)}/")
657
+ end
658
+ end
659
+
660
+ # Read the way HierarchicalProcessor#child_layout_processor reads it.
661
+ def different_algorithm?(child_graph)
662
+ name = Options::Resolver.new.get("elk.algorithm", child_graph, default: nil)
663
+ algorithm_class = name && AlgorithmRegistry.get(name)
664
+ !algorithm_class.nil? && !algorithm_class.equal?(self.class)
665
+ end
666
+
73
667
  # Position nodes if they don't have positions
74
668
  def position_nodes_if_needed(graph)
75
- return if graph.children.all? { |n| n.x && n.y }
669
+ pending = graph.children.reject { |n| n.x && n.y }
670
+ return if pending.empty?
671
+
672
+ anchored = graph.children - pending
673
+ if anchored.empty?
674
+ place_in_grid(pending, 0.0, 0.0)
675
+ else
676
+ place_beside(pending, anchored)
677
+ end
678
+ end
76
679
 
77
- # Use simple box layout for positioning
78
- spacing = node_spacing
79
- max_width = graph.children.map(&:width).max
80
- max_height = graph.children.map(&:height).max
680
+ # Positioned nodes are never moved: a connector router that shifts
681
+ # the caller's layout is not routing, it is re-laying-out.
682
+ def place_beside(pending, anchored)
683
+ origin_x = anchored.map { |n| n.x + n.width }.max + node_spacing
684
+ origin_y = anchored.map(&:y).min
685
+ place_in_grid(pending, origin_x, origin_y)
686
+ end
81
687
 
82
- cols = Math.sqrt(graph.children.length * 1.6).ceil
83
- cols = [cols, 1].max
688
+ def place_in_grid(nodes, origin_x, origin_y)
689
+ spacing = node_spacing
690
+ max_width = nodes.map(&:width).max
691
+ max_height = nodes.map(&:height).max
692
+ cols = [Math.sqrt(nodes.length * 1.6).ceil, 1].max
84
693
 
85
- graph.children.each_with_index do |node, i|
86
- row = i / cols
87
- col = i % cols
88
- node.x = col * (max_width + spacing)
89
- node.y = row * (max_height + spacing)
694
+ nodes.each_with_index do |node, i|
695
+ node.x = origin_x + ((i % cols) * (max_width + spacing))
696
+ node.y = origin_y + ((i / cols) * (max_height + spacing))
90
697
  end
91
698
  end
92
699
 
93
- # Build obstacle map from nodes
700
+ # Build the padded obstacle rectangle for every node, keyed by node id.
701
+ # Every node stays an obstacle for every edge, including the edge's own
702
+ # endpoints (see #route_single_edge). Normalizes x/width and y/height
703
+ # to the node's real footprint FIRST: Elkrb::Geometry::Rectangle does
704
+ # not normalize a negative width/height on construction, so padding a
705
+ # negative dimension directly (`width + 2*padding`) shrank and
706
+ # mis-positioned the obstacle instead of expanding its real extent
707
+ # by `padding` on every side.
94
708
  def build_obstacle_map(nodes)
95
- padding = option("libavoid.routingPadding", 10).to_f
96
-
97
- nodes.map do |node|
98
- Geometry::Rectangle.new(
99
- (node.x || 0) - padding,
100
- (node.y || 0) - padding,
101
- (node.width || 0) + (2 * padding),
102
- (node.height || 0) + (2 * padding),
709
+ padding = routing_padding
710
+
711
+ nodes.to_h do |node|
712
+ left, right = [node.x || 0.0, (node.x || 0.0) + (node.width || 0.0)].minmax
713
+ top, bottom = [node.y || 0.0, (node.y || 0.0) + (node.height || 0.0)].minmax
714
+
715
+ [node.id, Geometry::Rectangle.new(
716
+ left - padding,
717
+ top - padding,
718
+ (right - left) + (2 * padding),
719
+ (bottom - top) + (2 * padding),
720
+ )]
721
+ end
722
+ end
723
+
724
+ # Route every edge that joins two different nodes of this graph, or
725
+ # their ports, around obstacles. Self-loops (including a port back to
726
+ # its own node) go to the shared router instead, so a loop keeps its
727
+ # rectangular shape instead of collapsing to a single point.
728
+ def route_edges_with_obstacles(graph, obstacle_map, diagnostics_prefix)
729
+ # Raises ValidationError on a duplicate or missing id. Routable
730
+ # edges never reach the shared router, so nothing else would check.
731
+ node_map = NodeIndex.build(graph)
732
+ graph.edges.each do |edge|
733
+ ends = EdgeEnds.resolve(edge, node_map)
734
+ next route_edge_by_owner(edge, graph, node_map) unless ends
735
+
736
+ record_routing_status(
737
+ DiagnosticsKey.edge(diagnostics_prefix, edge.id),
738
+ route_single_edge(edge, ends, obstacle_map, graph),
103
739
  )
104
740
  end
105
741
  end
106
742
 
107
- # Route all edges with obstacle avoidance
108
- def route_edges_with_obstacles(graph, obstacles)
109
- return unless graph.edges&.any?
743
+ # Keeps a fallback status when a later edge shares the key and was found.
744
+ def record_routing_status(key, status)
745
+ @routing_diagnostics[key] = status unless @routing_diagnostics.fetch(key, :found) != :found
746
+ end
110
747
 
111
- node_map = build_node_map(graph)
748
+ # Dispatch for an edge whose ends did not resolve to two different
749
+ # nodes: a self-loop (plain, or a port back to its own node) goes to
750
+ # the shared router's loop routing; anything else (an edge that
751
+ # leaves this level) goes to its ordinary style-based routing.
752
+ def route_edge_by_owner(edge, graph, node_map)
753
+ if EdgeEnds.same_owner?(edge, node_map)
754
+ route_edge_as_self_loop(edge, graph)
755
+ else
756
+ route_edge_without_obstacles(edge, graph)
757
+ end
758
+ end
112
759
 
113
- graph.edges.each do |edge|
114
- route_single_edge(edge, node_map, obstacles)
760
+ # A self-loop, whether on a plain node or a port back to its own node.
761
+ def route_edge_as_self_loop(edge, graph)
762
+ node_map = NodeIndex.build(graph)
763
+ style = get_edge_routing_style(graph, edge)
764
+ route_self_loop(edge, node_map, graph, style)
765
+ pin_port_ends(edge, node_map)
766
+ end
767
+
768
+ # The shared router anchors a loop with only one port end (`a_p1 -> a`)
769
+ # on the node itself. Move that end back onto its port, keeping the old
770
+ # anchor as a bend reached through an orthogonal elbow.
771
+ def pin_port_ends(edge, node_map)
772
+ section = edge.sections&.first
773
+ return unless section
774
+
775
+ source_port = port_point(edge.sources.first, node_map)
776
+ target_port = port_point(edge.targets.first, node_map)
777
+ bends = section.bend_points || []
778
+ if source_port && !same_point?(source_port, section.start_point)
779
+ bends = [*port_elbow(source_port, section.start_point), section.start_point, *bends]
780
+ section.start_point = source_port
115
781
  end
782
+ if target_port && !same_point?(target_port, section.end_point)
783
+ bends = [*bends, section.end_point, *port_elbow(target_port, section.end_point)]
784
+ section.end_point = target_port
785
+ end
786
+ section.bend_points = bends
116
787
  end
117
788
 
118
- # Route a single edge around obstacles
119
- def route_single_edge(edge, node_map, obstacles)
120
- return unless edge.sources&.any? && edge.targets&.any?
789
+ def port_point(id, node_map)
790
+ node = node_map.node(id)
791
+ port = node&.ports&.find { |candidate| candidate.id == id }
792
+ port && get_port_absolute_position(port, node)
793
+ end
121
794
 
122
- source_id = edge.sources.first
123
- target_id = edge.targets.first
795
+ def port_elbow(port, anchor)
796
+ return [] if port.x == anchor.x || port.y == anchor.y
124
797
 
125
- source_node = node_map[source_id]
126
- target_node = node_map[target_id]
798
+ [Geometry::Point.new(x: anchor.x, y: port.y)]
799
+ end
127
800
 
128
- return unless source_node && target_node
801
+ def same_point?(one, other)
802
+ other && one.x == other.x && one.y == other.y
803
+ end
129
804
 
130
- # Get start and end points (node centers)
131
- start_point = get_node_center(source_node)
132
- end_point = get_node_center(target_node)
805
+ # The shared router's own dispatch (self-loop vs styled routing) for
806
+ # an edge that leaves this level entirely -- the id-based self_loop?
807
+ # is correct here since no node_map lookup resolved either endpoint.
808
+ def route_edge_without_obstacles(edge, graph)
809
+ node_index = NodeIndex.build(graph)
810
+ routing_style = get_edge_routing_style(graph, edge)
811
+
812
+ if self_loop?(edge, node_index)
813
+ route_self_loop(edge, node_index, graph, routing_style)
814
+ else
815
+ route_edge_with_style(edge, node_index, graph, routing_style)
816
+ end
817
+ end
133
818
 
134
- # Find path using A* algorithm
135
- path = find_path(start_point, end_point, obstacles)
819
+ # Route a single edge around obstacles and return its routing status
820
+ # (`:found`, `:capped` or `:no_path`).
821
+ #
822
+ # Both endpoints keep their own node in the obstacle list, reached
823
+ # by a short stub to a clearance point just outside that node's
824
+ # padding -- excluding it is unsafe even for a plain endpoint, since
825
+ # A* can double back through it once it is no longer in the
826
+ # obstacle set. `clearance_point` already handles a nil port, so
827
+ # ported and plain endpoints share this one path.
828
+ def route_single_edge(edge, ends, obstacle_map, graph)
829
+ source_id = edge.sources.first
830
+ target_id = edge.targets.first
831
+ start_point = endpoint_point(source_id, ends.source, ends.target)
832
+ end_point = endpoint_point(target_id, ends.target, ends.source)
833
+ departure = clearance_point(source_id, start_point, ends.source)
834
+ arrival = clearance_point(target_id, end_point, ends.target)
835
+
836
+ bbox = edge_bbox(departure, arrival)
837
+ nearby = obstacle_map.values.select { |rect| bbox.overlaps?(rect) }
838
+ obstacles = nearby.map { |rect| Bounds.relax(rect, routing_padding, [departure, arrival]) }
839
+
840
+ path, status = find_path(departure, arrival, obstacles)
841
+ if status == :found && stub_blocked?(ends, [start_point, departure, arrival, end_point], obstacle_map)
842
+ path = [departure, arrival]
843
+ status = :no_path
844
+ end
845
+ unless status == :found
846
+ warn "Libavoid: edge #{edge.id} used fallback routing (#{status})"
847
+ end
848
+
849
+ full_path = Waypoints.simplify([start_point, *path, end_point])
136
850
 
137
851
  # Create orthogonal segments from path
138
- bend_points = create_orthogonal_segments(path)
852
+ bend_points = create_orthogonal_segments(full_path)
139
853
 
140
854
  # Minimize bends
141
855
  bend_points = minimize_bends(bend_points, start_point, end_point,
142
856
  obstacles)
143
857
 
144
- # Apply to edge section
145
- edge.sections ||= []
146
- if edge.sections.empty?
147
- edge.sections << Graph::EdgeSection.new(id: "#{edge.id}_section_0")
148
- end
149
-
150
- section = edge.sections.first
858
+ # Apply to a fresh edge section. Input routes are stale once the
859
+ # obstacle search has recomputed this edge.
860
+ section = reset_section(edge, graph)
151
861
  section.start_point = start_point
152
862
  section.end_point = end_point
153
863
  section.bend_points = bend_points
864
+ status
154
865
  end
155
866
 
156
- # A* pathfinding algorithm
157
- def find_path(start, goal, obstacles)
158
- segment_penalty = option("libavoid.segmentPenalty", 1.0).to_f
159
- bend_penalty = option("libavoid.bendPenalty", 2.0).to_f
867
+ # True when a clearance stub (the short leg from an endpoint to its
868
+ # clearance point, which the search never covers) cuts through a node
869
+ # other than the one it leaves. `points` is `[start, departure,
870
+ # arrival, end]`; the other endpoint's node counts as a third node
871
+ # for each stub.
872
+ def stub_blocked?(ends, points, obstacle_map)
873
+ start_point, departure, arrival, end_point = points
874
+ [[start_point, departure, ends.source.id], [arrival, end_point, ends.target.id]]
875
+ .any? { |from, to, owner_id| collides_with_obstacles?(from, to, footprints_except(owner_id, obstacle_map)) }
876
+ end
160
877
 
161
- start_node = PathNode.new(start, nil, 0, heuristic(start, goal))
162
- open_set = [start_node]
163
- closed_set = {}
164
- g_scores = { point_key(start) => 0 }
878
+ def footprints_except(owner_id, obstacle_map)
879
+ obstacle_map.except(owner_id).values.map { |rect| Bounds.of(rect).inset(routing_padding) }
880
+ end
165
881
 
166
- while open_set.any?
167
- # Get node with lowest f_score
168
- current = open_set.min_by(&:f_score)
882
+ # A point just beyond `node`'s own padded obstacle rectangle, pushed
883
+ # straight out from `anchor` on whichever side it sits on: the side
884
+ # the port `id` declares when it is a port with one, otherwise
885
+ # derived from `anchor`'s own position relative to the node (a plain
886
+ # border-facing anchor already sits exactly on one side).
887
+ def clearance_point(id, anchor, node)
888
+ port_side = find_port_by_id(id, node)&.side
889
+ side = port_side || NodeBox.of(node).side_nearest(anchor)
890
+ Exit.new(anchor: anchor, side: side).clearance_point(clearance)
891
+ end
169
892
 
170
- # Goal reached
171
- if points_equal?(current.point, goal)
172
- return reconstruct_path(current)
173
- end
893
+ # How far beyond a node's border the edge's first and last point sit.
894
+ # The margin on top of the padding only needs to clear the inclusive
895
+ # boundary check by a positive amount -- a fixed 1.0 unit is fine at
896
+ # the usual coordinate scale (padding defaults to 10), but a graph
897
+ # laid out at a much smaller scale (a fractional step size implies
898
+ # one -- see the "grid step below one hundredth" spec) would have the
899
+ # search cover a margin 100x its own size, and blow the expansion cap
900
+ # on every edge. Never exceed `step_size`.
901
+ def clearance
902
+ routing_padding + [1.0, step_size].min
903
+ end
174
904
 
175
- open_set.delete(current)
176
- closed_set[point_key(current.point)] = true
905
+ # The endpoints' extent plus a margin that grows with their distance,
906
+ # so a detour fits but far-away parts of the graph are never searched.
907
+ def edge_bbox(start_point, end_point)
908
+ margin = [2 * SearchPoint.distance(start_point, end_point), 8 * step_size].max
909
+ SearchBox.around(start_point, end_point, margin)
910
+ end
177
911
 
178
- # Explore neighbors (orthogonal directions)
179
- neighbors = get_orthogonal_neighbors(current.point, goal, obstacles)
912
+ # The one reader for every numeric libavoid option, so none reaches
913
+ # the search unchecked. The resolver raises ValidationError on a
914
+ # non-numeric or non-finite value; a finite value outside
915
+ # `floor..OPTION_CEILING` reads as the nearer bound.
916
+ def numeric_option(key, floor:)
917
+ Float(option(key)).clamp(floor, OPTION_CEILING)
918
+ end
180
919
 
181
- neighbors.each do |neighbor_point, direction|
182
- key = point_key(neighbor_point)
183
- next if closed_set[key]
920
+ # Zero is the documented no-search case; a negative step would point
921
+ # the clearance stubs back through their own nodes.
922
+ def step_size
923
+ numeric_option("libavoid.stepSize", floor: 0.0)
924
+ end
184
925
 
185
- # Calculate cost with penalties for segments and direction changes
186
- distance = euclidean_distance(current.point, neighbor_point)
187
- direction_change_penalty = current.direction && current.direction != direction ? bend_penalty : 0
188
- tentative_g = current.g_score + distance + segment_penalty + direction_change_penalty
926
+ # A negative padding would shrink every obstacle below its node and
927
+ # let a route cut through it.
928
+ def routing_padding
929
+ numeric_option("libavoid.routingPadding", floor: 0.0)
930
+ end
189
931
 
190
- if !g_scores[key] || tentative_g < g_scores[key]
191
- g_scores[key] = tentative_g
192
- f_score = tentative_g + heuristic(neighbor_point, goal)
932
+ def max_expansions
933
+ numeric_option("libavoid.maxExpansions", floor: 0.0).to_i
934
+ end
193
935
 
194
- neighbor_node = PathNode.new(neighbor_point, current,
195
- tentative_g, f_score, direction)
936
+ # A negative penalty would reward the segments and bends it prices.
937
+ def segment_penalty
938
+ numeric_option("libavoid.segmentPenalty", floor: 0.0)
939
+ end
196
940
 
197
- # Add or update in open set
198
- existing = open_set.find do |n|
199
- points_equal?(n.point, neighbor_point)
200
- end
201
- if existing
202
- open_set.delete(existing)
203
- end
204
- open_set << neighbor_node
205
- end
941
+ def bend_penalty
942
+ numeric_option("libavoid.bendPenalty", floor: 0.0)
943
+ end
944
+
945
+ # A* pathfinding, bounded to the edge's search box and capped at `max_expansions`
946
+ # expansions. Returns `[path_points, status]`, `status` one of
947
+ # `:found`, `:capped` (the cap was hit), or `:no_path` (the goal is
948
+ # unreachable within the bbox even ignoring the cap). A `:capped` or
949
+ # `:no_path` result still returns a direct `[start, goal]` line; the
950
+ # caller decides how to report it.
951
+ def find_path(start, goal, obstacles)
952
+ # A non-finite start or goal (e.g. a node with an Infinity/NaN
953
+ # coordinate) breaks the grid math before any obstacle is even
954
+ # considered: `SearchPoint.grid_key` keys the start against
955
+ # itself, and `Infinity - Infinity` is NaN regardless of step
956
+ # size, well before `numeric_option`'s own sanitizing (which only
957
+ # covers OPTIONS, not node/port geometry) ever applies. Treat it
958
+ # the same as an unreachable goal rather than letting the search
959
+ # raise deep inside the grid math.
960
+ unless start.x.finite? && start.y.finite? && goal.x.finite? && goal.y.finite?
961
+ return [[start, goal], :no_path]
962
+ end
963
+
964
+ cap = max_expansions
965
+ return [[start, goal], :capped] if cap.zero?
966
+
967
+ bbox = edge_bbox(start, goal)
968
+ # Normalised once: every neighbour test below runs against each one.
969
+ obstacles = obstacles.map { |obstacle| Bounds.of(obstacle) }
970
+
971
+ step = step_size
972
+ plan = SearchPlan.new(goal: goal, step: step, segment: segment_penalty, bend: bend_penalty)
973
+ heap = OpenSetHeap.new
974
+ node_for_key = {}
975
+ closed = {}
976
+ sequence = 0
977
+
978
+ start_key = [SearchPoint.grid_key(start, start, step), nil]
979
+ start_node = SearchNode.new(point: start, parent: nil, g_score: 0.0,
980
+ f_score: plan.estimate(start), direction: nil)
981
+ node_for_key[start_key] = start_node
982
+ heap.push(start_node.f_score, sequence, start_key)
983
+
984
+ expansions = 0
985
+
986
+ until heap.empty?
987
+ _f, _seq, key = heap.pop
988
+ next if closed[key]
989
+
990
+ current = node_for_key[key]
991
+ return [reconstruct_path(current), :found] if key == GOAL_KEY
992
+
993
+ closed[key] = true
994
+ expansions += 1
995
+ # The goal is a continuous border point that the step grid rarely
996
+ # contains, so any grid point with a clear L-shaped route to it --
997
+ # not only one a step away -- may finish the search. The hop is
998
+ # costed like any other step.
999
+ corner = clear_corner(current.point, goal, obstacles)
1000
+ hop = corner && current.extend_through(corner, goal, plan)
1001
+ if hop && hop.g_score < (node_for_key[GOAL_KEY]&.g_score || Float::INFINITY)
1002
+ node_for_key[GOAL_KEY] = hop
1003
+ sequence += 1
1004
+ heap.push(hop.f_score, sequence, GOAL_KEY)
1005
+ end
1006
+ if expansions >= cap
1007
+ best = node_for_key[GOAL_KEY]
1008
+ return best ? [reconstruct_path(best), :found] : [[start, goal], :capped]
1009
+ end
1010
+
1011
+ get_orthogonal_neighbors(current.point, obstacles, bbox).each do |neighbor_point, direction|
1012
+ # The bend penalty depends on how a point was reached, so the
1013
+ # same point reached along the other axis is a different state.
1014
+ neighbor_key = [SearchPoint.grid_key(neighbor_point, start, step), direction]
1015
+ next if closed[neighbor_key]
1016
+
1017
+ neighbor = current.extend_to(neighbor_point, direction, plan)
1018
+ existing = node_for_key[neighbor_key]
1019
+ next if existing && neighbor.g_score >= existing.g_score
1020
+
1021
+ node_for_key[neighbor_key] = neighbor
1022
+ sequence += 1
1023
+ heap.push(neighbor.f_score, sequence, neighbor_key)
206
1024
  end
207
1025
  end
208
1026
 
209
- # No path found, return direct path
210
- [start, goal]
1027
+ [[start, goal], :no_path]
1028
+ end
1029
+
1030
+ # The corner of an L-shaped route from `point` to `goal` whose two
1031
+ # legs both miss every obstacle, or nil when neither corner does.
1032
+ def clear_corner(point, goal, obstacles)
1033
+ [SearchPoint.new(x: goal.x, y: point.y),
1034
+ SearchPoint.new(x: point.x, y: goal.y)].find do |corner|
1035
+ !collides_with_obstacles?(point, corner, obstacles) &&
1036
+ !collides_with_obstacles?(corner, goal, obstacles)
1037
+ end
211
1038
  end
212
1039
 
213
- # Get orthogonal neighbors (4-directional)
214
- def get_orthogonal_neighbors(point, _goal, obstacles)
215
- step_size = option("libavoid.routingPadding", 10).to_f
1040
+ # Get orthogonal neighbors (4-directional), dropping any candidate
1041
+ # outside `bbox` before checking obstacle collision.
1042
+ def get_orthogonal_neighbors(point, obstacles, bbox)
1043
+ step = step_size
216
1044
  neighbors = []
217
1045
 
218
- # Four orthogonal directions
219
1046
  [
220
- [step_size, 0, :horizontal], # right
221
- [-step_size, 0, :horizontal], # left
222
- [0, step_size, :vertical], # down
223
- [0, -step_size, :vertical], # up
1047
+ [step, 0, :horizontal], # right
1048
+ [-step, 0, :horizontal], # left
1049
+ [0, step, :vertical], # down
1050
+ [0, -step, :vertical], # up
224
1051
  ].each do |dx, dy, direction|
225
- neighbor = Geometry::Point.new(x: point.x + dx, y: point.y + dy)
1052
+ neighbor = SearchPoint.new(x: point.x + dx, y: point.y + dy)
1053
+ next unless bbox.contains?(neighbor)
226
1054
 
227
1055
  # Skip if it collides with obstacles
228
1056
  unless collides_with_obstacles?(point, neighbor, obstacles)
@@ -242,50 +1070,14 @@ f_score = Float::INFINITY, direction = nil)
242
1070
 
243
1071
  # Check if line segment intersects rectangle
244
1072
  def line_intersects_rectangle?(p1, p2, rect)
245
- # Check if either endpoint is inside rectangle
246
- return true if point_in_rectangle?(p1,
247
- rect) || point_in_rectangle?(p2,
248
- rect)
1073
+ return true if point_in_rectangle?(p1, rect) || point_in_rectangle?(p2, rect)
249
1074
 
250
- # Check if line intersects any edge of rectangle
251
- rect_edges = [
252
- [Geometry::Point.new(x: rect.x, y: rect.y),
253
- Geometry::Point.new(x: rect.x + rect.width, y: rect.y)],
254
- [Geometry::Point.new(x: rect.x + rect.width, y: rect.y),
255
- Geometry::Point.new(x: rect.x + rect.width,
256
- y: rect.y + rect.height)],
257
- [Geometry::Point.new(x: rect.x + rect.width, y: rect.y + rect.height),
258
- Geometry::Point.new(x: rect.x, y: rect.y + rect.height)],
259
- [Geometry::Point.new(x: rect.x, y: rect.y + rect.height),
260
- Geometry::Point.new(x: rect.x, y: rect.y)],
261
- ]
262
-
263
- rect_edges.any? do |edge_p1, edge_p2|
264
- segments_intersect?(p1, p2, edge_p1, edge_p2)
265
- end
1075
+ Bounds.of(rect).crossed_by?(p1, p2)
266
1076
  end
267
1077
 
268
1078
  # Check if point is inside rectangle
269
1079
  def point_in_rectangle?(point, rect)
270
- point.x.between?(rect.x, rect.x + rect.width) &&
271
- point.y >= rect.y &&
272
- point.y <= rect.y + rect.height
273
- end
274
-
275
- # Check if two line segments intersect
276
- def segments_intersect?(p1, p2, p3, p4)
277
- d1 = direction(p3, p4, p1)
278
- d2 = direction(p3, p4, p2)
279
- d3 = direction(p1, p2, p3)
280
- d4 = direction(p1, p2, p4)
281
-
282
- ((d1.positive? && d2.negative?) || (d1.negative? && d2.positive?)) &&
283
- ((d3.positive? && d4.negative?) || (d3.negative? && d4.positive?))
284
- end
285
-
286
- # Calculate direction for line segment intersection
287
- def direction(p1, p2, p3)
288
- ((p3.x - p1.x) * (p2.y - p1.y)) - ((p2.x - p1.x) * (p3.y - p1.y))
1080
+ Bounds.of(rect).include?(point)
289
1081
  end
290
1082
 
291
1083
  # Reconstruct path from A* result
@@ -301,18 +1093,6 @@ f_score = Float::INFINITY, direction = nil)
301
1093
  path
302
1094
  end
303
1095
 
304
- # Manhattan distance heuristic for A*
305
- def heuristic(point, goal)
306
- (point.x - goal.x).abs + (point.y - goal.y).abs
307
- end
308
-
309
- # Euclidean distance
310
- def euclidean_distance(p1, p2)
311
- dx = p2.x - p1.x
312
- dy = p2.y - p1.y
313
- Math.sqrt((dx * dx) + (dy * dy))
314
- end
315
-
316
1096
  # Create orthogonal segments from path
317
1097
  def create_orthogonal_segments(path)
318
1098
  return [] if path.length < 3
@@ -336,10 +1116,11 @@ f_score = Float::INFINITY, direction = nil)
336
1116
  while i < all_points.length - 1
337
1117
  j = all_points.length - 1
338
1118
 
339
- # Try to connect point i to furthest visible point
1119
+ # Try to connect point i to the furthest point it can reach in
1120
+ # one horizontal or vertical line without hitting an obstacle
340
1121
  while j > i + 1
341
- unless collides_with_obstacles?(all_points[i], all_points[j],
342
- obstacles)
1122
+ if SearchPoint.aligned?(all_points[i], all_points[j]) &&
1123
+ !collides_with_obstacles?(all_points[i], all_points[j], obstacles)
343
1124
  simplified << all_points[j]
344
1125
  i = j
345
1126
  break
@@ -358,30 +1139,13 @@ f_score = Float::INFINITY, direction = nil)
358
1139
  simplified[1..-2] || []
359
1140
  end
360
1141
 
361
- # Helper: create unique key for point
362
- def point_key(point)
363
- "#{point.x.round(2)},#{point.y.round(2)}"
364
- end
365
-
366
- # Helper: check if two points are equal (within tolerance)
367
- def points_equal?(p1, p2, tolerance = 0.1)
368
- (p1.x - p2.x).abs < tolerance && (p1.y - p2.y).abs < tolerance
369
- end
370
-
371
- # Build node map from graph
372
- def build_node_map(graph)
373
- map = {}
374
- graph.children&.each do |node|
375
- map[node.id] = node
376
- end
377
- map
378
- end
1142
+ # Where an edge meets `node`: the port's position when `id` is a
1143
+ # port, otherwise the node boundary facing `other` -- so a "no bend
1144
+ # needed" edge touches the node rather than running through it.
1145
+ def endpoint_point(id, node, other)
1146
+ return get_port_position(id, node, nil) if find_port_by_id(id, node)
379
1147
 
380
- # Get center point of a node
381
- def get_node_center(node)
382
- x = (node.x || 0.0) + ((node.width || 0.0) / 2.0)
383
- y = (node.y || 0.0) + ((node.height || 0.0) / 2.0)
384
- Geometry::Point.new(x: x, y: y)
1148
+ NodeBox.of(node).border_toward(get_node_center(other))
385
1149
  end
386
1150
  end
387
1151
  end