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.
- checksums.yaml +4 -4
- data/CHANGELOG.adoc +364 -0
- data/LICENSE +25 -0
- data/README.adoc +695 -557
- data/exe/elkrb +36 -2
- data/lib/elkrb/atomic_destination.rb +53 -0
- data/lib/elkrb/best_effort_write.rb +49 -0
- data/lib/elkrb/cli/layout_flag_options.rb +54 -0
- data/lib/elkrb/cli.rb +155 -82
- data/lib/elkrb/command_resolver.rb +186 -0
- data/lib/elkrb/commands/batch_command.rb +28 -5
- data/lib/elkrb/commands/convert_command.rb +22 -52
- data/lib/elkrb/commands/diagram_command.rb +234 -102
- data/lib/elkrb/commands/options_command.rb +87 -0
- data/lib/elkrb/commands/render_command.rb +6 -1
- data/lib/elkrb/commands/validate_command.rb +146 -91
- data/lib/elkrb/errors.rb +20 -1
- data/lib/elkrb/format_sniffer.rb +362 -0
- data/lib/elkrb/geometry/bezier.rb +14 -4
- data/lib/elkrb/geometry/point.rb +5 -6
- data/lib/elkrb/geometry/rectangle.rb +2 -2
- data/lib/elkrb/graph/deep_stringify_keys.rb +30 -0
- data/lib/elkrb/graph/edge.rb +89 -3
- data/lib/elkrb/graph/graph.rb +43 -15
- data/lib/elkrb/graph/label.rb +16 -2
- data/lib/elkrb/graph/node.rb +13 -2
- data/lib/elkrb/graph/node_constraints.rb +139 -8
- data/lib/elkrb/graph/normalize_option_keys.rb +39 -0
- data/lib/elkrb/graph/port.rb +29 -21
- data/lib/elkrb/graph/read_only_mapping.rb +21 -0
- data/lib/elkrb/graphviz_wrapper.rb +152 -44
- data/lib/elkrb/layout/algorithm_registry.rb +65 -8
- data/lib/elkrb/layout/algorithms/base_algorithm.rb +74 -74
- data/lib/elkrb/layout/algorithms/box.rb +47 -20
- data/lib/elkrb/layout/algorithms/disco.rb +150 -61
- data/lib/elkrb/layout/algorithms/fixed.rb +70 -10
- data/lib/elkrb/layout/algorithms/force.rb +145 -86
- data/lib/elkrb/layout/algorithms/layered/bk/aligner.rb +104 -0
- data/lib/elkrb/layout/algorithms/layered/bk/compactor.rb +87 -0
- data/lib/elkrb/layout/algorithms/layered/bk/view.rb +103 -0
- data/lib/elkrb/layout/algorithms/layered/bk_node_placer.rb +170 -0
- data/lib/elkrb/layout/algorithms/layered/components.rb +170 -0
- data/lib/elkrb/layout/algorithms/layered/crossing_minimizer.rb +306 -0
- data/lib/elkrb/layout/algorithms/layered/cycle_breaker.rb +118 -49
- data/lib/elkrb/layout/algorithms/layered/discovery_order.rb +75 -0
- data/lib/elkrb/layout/algorithms/layered/dummy_slot.rb +31 -0
- data/lib/elkrb/layout/algorithms/layered/layer_assigner.rb +188 -48
- data/lib/elkrb/layout/algorithms/layered/layer_constraints.rb +82 -0
- data/lib/elkrb/layout/algorithms/layered/layer_sweep.rb +195 -0
- data/lib/elkrb/layout/algorithms/layered/node_placer.rb +214 -36
- data/lib/elkrb/layout/algorithms/layered/orthogonal/gap_solver.rb +219 -0
- data/lib/elkrb/layout/algorithms/layered/orthogonal/hyper_edge_segment.rb +152 -0
- data/lib/elkrb/layout/algorithms/layered/orthogonal/junction_points.rb +58 -0
- data/lib/elkrb/layout/algorithms/layered/orthogonal/segment_order.rb +134 -0
- data/lib/elkrb/layout/algorithms/layered/orthogonal_router.rb +253 -0
- data/lib/elkrb/layout/algorithms/layered/port_order.rb +188 -0
- data/lib/elkrb/layout/algorithms/layered/port_spread.rb +42 -0
- data/lib/elkrb/layout/algorithms/layered.rb +548 -13
- data/lib/elkrb/layout/algorithms/libavoid.rb +951 -187
- data/lib/elkrb/layout/algorithms/mrtree.rb +585 -65
- data/lib/elkrb/layout/algorithms/radial.rb +188 -29
- data/lib/elkrb/layout/algorithms/random.rb +131 -17
- data/lib/elkrb/layout/algorithms/rectpacking.rb +28 -40
- data/lib/elkrb/layout/algorithms/spore_compaction.rb +57 -100
- data/lib/elkrb/layout/algorithms/spore_overlap.rb +78 -84
- data/lib/elkrb/layout/algorithms/stress.rb +391 -84
- data/lib/elkrb/layout/algorithms/topdown_packing.rb +95 -31
- data/lib/elkrb/layout/algorithms/vertiflex.rb +12 -47
- data/lib/elkrb/layout/constraints/alignment_constraint.rb +49 -12
- data/lib/elkrb/layout/constraints/base_constraint.rb +3 -15
- data/lib/elkrb/layout/constraints/constraint_processor.rb +9 -23
- data/lib/elkrb/layout/constraints/fixed_position_constraint.rb +22 -13
- data/lib/elkrb/layout/constraints/layer_constraint.rb +10 -33
- data/lib/elkrb/layout/constraints/relative_position_constraint.rb +94 -50
- data/lib/elkrb/layout/edge_router.rb +533 -337
- data/lib/elkrb/layout/hierarchical_processor.rb +19 -276
- data/lib/elkrb/layout/java_random.rb +59 -0
- data/lib/elkrb/layout/label_placer.rb +165 -157
- data/lib/elkrb/layout/layout_engine.rb +46 -23
- data/lib/elkrb/layout/node_index.rb +149 -0
- data/lib/elkrb/layout/polyomino/component_compactor.rb +138 -0
- data/lib/elkrb/layout/polyomino/footprint.rb +94 -0
- data/lib/elkrb/layout/polyomino/grid.rb +106 -0
- data/lib/elkrb/layout/polyomino/packer.rb +95 -0
- data/lib/elkrb/layout/polyomino/piece.rb +52 -0
- data/lib/elkrb/layout/port_constraint_processor.rb +131 -44
- data/lib/elkrb/layout/spore/body.rb +65 -0
- data/lib/elkrb/layout/spore/depth_first_compaction.rb +127 -0
- data/lib/elkrb/layout/spore/edge_cost.rb +69 -0
- data/lib/elkrb/layout/spore/gap.rb +76 -0
- data/lib/elkrb/layout/spore/geometry.rb +104 -0
- data/lib/elkrb/layout/spore/overlap_sweep.rb +90 -0
- data/lib/elkrb/layout/spore/placement.rb +57 -0
- data/lib/elkrb/layout/spore/rays.rb +119 -0
- data/lib/elkrb/layout/spore/spanning_tree.rb +53 -0
- data/lib/elkrb/layout/spore/triangulation.rb +159 -0
- data/lib/elkrb/layout_flags.rb +69 -0
- data/lib/elkrb/options/decimal.rb +50 -0
- data/lib/elkrb/options/element_options.rb +43 -0
- data/lib/elkrb/options/elk_padding.rb +20 -14
- data/lib/elkrb/options/k_vector.rb +6 -7
- data/lib/elkrb/options/k_vector_chain.rb +12 -20
- data/lib/elkrb/options/option_map.rb +85 -0
- data/lib/elkrb/options/registry.rb +335 -0
- data/lib/elkrb/options/resolver.rb +151 -0
- data/lib/elkrb/options/spellings.rb +62 -0
- data/lib/elkrb/options/unhonoured_report.rb +194 -0
- data/lib/elkrb/parsers/elkt/lexer.rb +309 -0
- data/lib/elkrb/parsers/elkt/parser.rb +424 -0
- data/lib/elkrb/parsers/elkt/resolver.rb +142 -0
- data/lib/elkrb/parsers/elkt/token.rb +40 -0
- data/lib/elkrb/parsers/elkt_parser.rb +32 -230
- data/lib/elkrb/serializers/dot_serializer.rb +342 -54
- data/lib/elkrb/serializers/elkt_serializer.rb +386 -155
- data/lib/elkrb/version.rb +1 -1
- data/lib/elkrb.rb +60 -61
- metadata +71 -49
- data/.rspec +0 -3
- data/.rubocop.yml +0 -11
- data/Gemfile +0 -13
- data/Rakefile +0 -64
- data/benchmarks/README.md +0 -172
- data/benchmarks/elkjs_benchmark.js +0 -140
- data/benchmarks/elkrb_benchmark.rb +0 -145
- data/benchmarks/fixtures/graphs.json +0 -10777
- data/benchmarks/generate_report.rb +0 -241
- data/benchmarks/generate_test_graphs.rb +0 -154
- data/benchmarks/results/elkrb_results.json +0 -280
- data/benchmarks/results/elkrb_summary.json +0 -285
- data/elkrb.gemspec +0 -39
- data/examples/dot_export_demo.rb +0 -133
- data/examples/hierarchical_graph.rb +0 -19
- data/examples/layout_constraints_demo.rb +0 -272
- data/examples/port_constraints_demo.rb +0 -291
- data/examples/self_loop_demo.rb +0 -391
- data/examples/simple_graph.rb +0 -50
- data/examples/spline_routing_demo.rb +0 -235
- data/lib/elkrb/graph/layout_options.rb +0 -247
- data/sig/elkrb/constraints.rbs +0 -114
- data/sig/elkrb/geometry.rbs +0 -61
- data/sig/elkrb/graph.rbs +0 -112
- data/sig/elkrb/layout.rbs +0 -107
- data/sig/elkrb/options.rbs +0 -81
- 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
|
-
# -
|
|
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
|
-
#
|
|
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
|
-
#
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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 =
|
|
96
|
-
|
|
97
|
-
nodes.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
#
|
|
108
|
-
def
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
123
|
-
|
|
795
|
+
def port_elbow(port, anchor)
|
|
796
|
+
return [] if port.x == anchor.x || port.y == anchor.y
|
|
124
797
|
|
|
125
|
-
|
|
126
|
-
|
|
798
|
+
[Geometry::Point.new(x: anchor.x, y: port.y)]
|
|
799
|
+
end
|
|
127
800
|
|
|
128
|
-
|
|
801
|
+
def same_point?(one, other)
|
|
802
|
+
other && one.x == other.x && one.y == other.y
|
|
803
|
+
end
|
|
129
804
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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(
|
|
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.
|
|
146
|
-
|
|
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
|
-
#
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
932
|
+
def max_expansions
|
|
933
|
+
numeric_option("libavoid.maxExpansions", floor: 0.0).to_i
|
|
934
|
+
end
|
|
193
935
|
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
[
|
|
221
|
-
[-
|
|
222
|
-
[0,
|
|
223
|
-
[0, -
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
342
|
-
|
|
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
|
-
#
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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
|