ea 0.6.138 → 0.6.139

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0c2231b8d009e8d99c841920776cbe8539e09b76d512beed109f073fb7904282
4
- data.tar.gz: 3feade927368b299842047c9c0e31aa0cb8473e7dad6870e5626fb369e1503de
3
+ metadata.gz: 34c3f680cb51a88a683e57db2c059fcee8979e5b6b31cfaae4c3cbc3ac91ac86
4
+ data.tar.gz: a4cbd25958c54c24d6d1801bf4e4cc83f76f42d652f371cf9d491865160d7725
5
5
  SHA512:
6
- metadata.gz: 025f36b456316a6fcd801318f17c27363d62d3ee7355cd303c1b7147081f44d3b70fdf711966ae4a5a7fc33e022dc097ba4d6f15f6c3ad03e237d59318b89fac
7
- data.tar.gz: d2487edce57559735c9cdf42984dd16ad3597985fb5d2f01ddeb9289c893b423a986f8587b3788d0cd759aaca93661c8e0a86262a4939f503d21e9495e29db9f
6
+ metadata.gz: c87682bc64b73abb6502c0dfd71ca7b9209ab8bcd71b79762032d0c4008f80999e3785257b72f39d73a816247d1dbcc5580a70e64f526a8ee98ee1aab32b645a
7
+ data.tar.gz: ed3c4dd24e1ed8b1bd9c371545d56392cda07b2e0f3223f27d84761b728963c5df7df67fab8da905bdd586150fb94750a5cbe3f2bd1bec9fd11adfd3ec18b9c6
@@ -280,7 +280,9 @@ module Ea
280
280
 
281
281
  metadata = stored_ray_metadata(connector, placed, source, target)
282
282
  metadata.each { |name, value| connector.public_send("#{name}=", value) }
283
- connector.style[:association_ray_outline] = true unless metadata.empty?
283
+ return if metadata.empty? || connector.connector_type == "Generalization"
284
+
285
+ connector.style[:association_ray_outline] = true
284
286
  end
285
287
 
286
288
  def stored_ray_metadata(connector, placed, source, target, stage: :ray_metadata)
@@ -26,9 +26,12 @@ module Ea
26
26
  finish(points, link, connector, source, target, bends, ports, offsets, edge)
27
27
  end
28
28
 
29
- # Direct custom-line associations and aggregations retain ray origins as
30
- # well as their computed boundary ports, just as the QEA builder does.
29
+ # Saved rays retain origins and computed boundary ports, matching QEA.
31
30
  def ray_metadata(model, link:, bounds:, offsets:, edge:)
31
+ if model.connector_type == "Generalization"
32
+ return generalization_ray_metadata(model, link, bounds, offsets, edge)
33
+ end
34
+
32
35
  return {} unless direct_association_ray?(model, bounds, offsets)
33
36
 
34
37
  source, target = bounds.values_at(:source, :target)
@@ -169,6 +172,39 @@ module Ea
169
172
  bounds.values.all? && offsets.values.flatten.none?(&:nil?)
170
173
  end
171
174
 
175
+ # A direct Generalization clips its saved mathematical origins rather
176
+ # than treating boundary ports as the origins of the final drawn ray.
177
+ def generalization_ray_metadata(model, link, bounds, offsets, edge)
178
+ geom = ray_geometry(offsets, edge)
179
+ return {} unless generalization_ray_candidate?(model, link, bounds, offsets, geom)
180
+
181
+ source, target = bounds.values_at(:source, :target)
182
+ return {} unless stored_ray_gap?(source, target, model.style[:mode], edge)
183
+
184
+ points = ray_points(source, target, offsets, geom)
185
+ return {} if points.empty?
186
+
187
+ points.merge(style: generalization_ray_style(model, points, source, target))
188
+ end
189
+
190
+ def generalization_ray_candidate?(model, link, bounds, offsets, geom)
191
+ model.waypoints.size == 2 && stored_ray_style?(model.style, link) &&
192
+ bounds.values.all? && offsets.values.flatten.none?(&:nil?) &&
193
+ (!geom[:sy].zero? || !geom[:ey].zero?)
194
+ end
195
+
196
+ def generalization_ray_style(model, points, source, target)
197
+ candidate = Ea::Model::DiagramConnector.new(
198
+ connector_type: model.connector_type, style: model.style.to_h.dup,
199
+ source_anchor: points[:source_anchor], target_anchor: points[:target_anchor]
200
+ )
201
+ return model.style.to_h unless centered_origin_ray?(candidate, source, target)
202
+
203
+ flags = { centered_origin_ray: true }
204
+ flags[:stored_origin_ray] = true if stored_origin_ray?(candidate, source, target)
205
+ model.style.to_h.merge(flags)
206
+ end
207
+
172
208
  def ray_geometry(offsets, edge)
173
209
  { sx: offsets[:source][0], sy: offsets[:source][1],
174
210
  ex: offsets[:target][0], ey: offsets[:target][1], edge: edge }
@@ -9,6 +9,7 @@ module Ea
9
9
  # connector passes over a crossing.
10
10
  class ConnectorBridges
11
11
  RADIUS = 6
12
+ PHANTOM = :phantom
12
13
 
13
14
  def initialize(_diagram, routes)
14
15
  @routes = routes.map { |connector, points| [connector, points, priority_for(connector)] }
@@ -56,78 +57,75 @@ module Ea
56
57
 
57
58
  # nil or empty: no bridge decision for this connector.
58
59
  def priority_for(connector)
60
+ return PHANTOM if phantom?(connector)
61
+
59
62
  connector.paint_order if connector.paint_order&.any?
60
63
  end
61
64
 
62
- def crossings_for(connector, priority, first, last)
63
- axis = axis_for(first, last)
64
- return [] unless axis
65
+ # A phantom (relationship without a link row) has no stored order; it
66
+ # is appended after every link in the export, so it passes over them.
67
+ def phantom?(connector)
68
+ connector.paint_order.nil?
69
+ end
65
70
 
71
+ def crossings_for(connector, priority, first, last)
66
72
  crossings = lower_routes(connector, priority).flat_map do |_partner, points, _order|
67
73
  points.each_cons(2).filter_map do |start, finish|
68
- crossing(first, last, start, finish, axis)
74
+ crossing(first, last, start, finish)
69
75
  end
70
76
  end
71
- separate_crossings(crossings, axis).sort_by { |point| (point[axis] - first[axis]).abs }
77
+ separate_crossings(crossings, first, last).sort_by { |point| distance(point, first) }
72
78
  end
73
79
 
74
80
  # The order key is only proven across different start placements, so
75
81
  # connectors leaving the same placement make no carrier decision.
76
82
  def lower_routes(connector, priority)
83
+ return keyed_routes(connector) if priority == PHANTOM
84
+
77
85
  @routes.select do |partner, _points, order|
78
- !partner.equal?(connector) && order && order.size == priority.size &&
86
+ !partner.equal?(connector) && order.is_a?(Array) && order.size == priority.size &&
79
87
  !same_source?(connector, partner) && (order <=> priority).negative?
80
88
  end
81
89
  end
82
90
 
91
+ def keyed_routes(connector)
92
+ @routes.select { |partner, _points, order| !partner.equal?(connector) && order.is_a?(Array) }
93
+ end
94
+
83
95
  def same_source?(connector, partner)
84
96
  connector.source_element_ref && connector.source_element_ref == partner.source_element_ref
85
97
  end
86
98
 
87
- # Suppress both overlapping spans, including coincident crossings,
88
- # so route order cannot select one of several ambiguous arcs.
89
- def separate_crossings(crossings, axis)
90
- crossings.reject.with_index do |point, index|
91
- crossings.each_with_index.any? do |other, other_index|
92
- index != other_index && (point[axis] - other[axis]).abs < 2 * RADIUS
93
- end
99
+ # Partners crossing at one point share a single arc (0BE81AC5 draws one
100
+ # arc for three coincident verticals). Distinct crossings whose spans
101
+ # overlap are all suppressed so route order cannot select one of
102
+ # several ambiguous arcs.
103
+ def separate_crossings(crossings, first, last)
104
+ crossings = crossings.uniq { |point| point.map { |value| value.round(6) } }
105
+ axis = SegmentGeometry.dominant_axis(first, last)
106
+ spans = crossings.map do |point|
107
+ SegmentGeometry.chord(first, last, point, RADIUS).map { |seat| seat[axis] }.minmax
94
108
  end
109
+ crossings.reject.with_index { |_point, index| overlapping_span?(spans, index) }
95
110
  end
96
111
 
97
- def crossing(first, last, start, finish, axis)
98
- return unless axis_for(start, finish) == 1 - axis
99
-
100
- point = axis.zero? ? [start[0], first[1]] : [first[0], start[1]]
101
- return unless segment_interior?(point, first, last, axis) &&
102
- segment_interior?(point, start, finish, 1 - axis)
103
-
104
- point
105
- end
106
-
107
- def segment_interior?(point, first, last, axis)
108
- interior?(point[axis], first[axis], last[axis])
112
+ def overlapping_span?(spans, index)
113
+ minimum, maximum = spans[index]
114
+ spans.each_with_index.any? do |(other_minimum, other_maximum), other_index|
115
+ index != other_index && minimum < other_maximum && other_minimum < maximum
116
+ end
109
117
  end
110
118
 
111
- def interior?(value, first, last)
112
- minimum, maximum = [first, last].minmax
113
- value > minimum && value < maximum &&
114
- value - minimum >= RADIUS && maximum - value >= RADIUS
119
+ def crossing(first, last, start, finish)
120
+ SegmentGeometry.crossing(first, last, start, finish, RADIUS)
115
121
  end
116
122
 
117
- def axis_for(first, last)
118
- return 0 if first[1] == last[1] && first[0] != last[0]
119
- return 1 if first[0] == last[0] && first[1] != last[1]
120
-
121
- nil
123
+ def distance(point, other)
124
+ SegmentGeometry.distance(point, other)
122
125
  end
123
126
 
124
127
  def arc_commands(first, last, crossing)
125
- axis = axis_for(first, last)
126
- direction = last[axis] > first[axis] ? 1 : -1
127
- start = crossing.dup
128
- finish = crossing.dup
129
- start[axis] -= direction * RADIUS
130
- finish[axis] += direction * RADIUS
128
+ start, finish = SegmentGeometry.chord(first, last, crossing, RADIUS)
131
129
  ["L #{coordinates(start)}", "A #{RADIUS} #{RADIUS} 180 0 0 #{coordinates(finish)}"]
132
130
  end
133
131
 
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Svg
5
+ module EaEmitter
6
+ # Segment arithmetic for connector bridges: where two segments cross at
7
+ # any slope, and where the arc chord sits on the carrier segment.
8
+ module SegmentGeometry
9
+ EPSILON = 1e-9
10
+
11
+ module_function
12
+
13
+ def distance(point, other)
14
+ Math.hypot(point[0] - other[0], point[1] - other[1])
15
+ end
16
+
17
+ # 0 for a horizontal segment, 1 for a vertical one, nil otherwise.
18
+ def axis_for(first, last)
19
+ return 0 if first[1] == last[1] && first[0] != last[0]
20
+ return 1 if first[0] == last[0] && first[1] != last[1]
21
+
22
+ nil
23
+ end
24
+
25
+ # A proper crossing of two segments; parallel and collinear segments
26
+ # never cross. Each span must leave `radius` of room either side.
27
+ def crossing(first, last, start, finish, radius)
28
+ point = axis_crossing(first, last, start, finish) ||
29
+ slanted_crossing(first, last, start, finish)
30
+ return unless point && room?(point, first, last, radius) && room?(point, start, finish, radius)
31
+ return unless chord_fits?(first, last, point, radius)
32
+
33
+ point
34
+ end
35
+
36
+ # The arc chord, one radius either side of the crossing along the
37
+ # carrier's dominant axis. A horizontal/vertical carrier keeps the
38
+ # exact crossing. On a slanted carrier EA seats the centre on the
39
+ # integer grid at floor(crossing) and offsets it by the half-chord
40
+ # (radius, truncated radius * slope), signed with the line direction.
41
+ def chord(first, last, crossing, radius)
42
+ major = dominant_axis(first, last)
43
+ step = last[major] > first[major] ? radius : -radius
44
+ return axis_chord(first, crossing, major, step) if first[1 - major] == last[1 - major]
45
+
46
+ slanted_chord(first, last, crossing, major, step)
47
+ end
48
+
49
+ def slanted_chord(first, last, crossing, major, step)
50
+ centre = crossing.map { |value| (value + EPSILON).floor }
51
+ half = half_chord(first, last, major, step)
52
+ [-1, 1].map { |sign| centre.zip(half).map { |c, h| c + sign * h } }
53
+ end
54
+
55
+ def half_chord(first, last, major, step)
56
+ minor = 1 - major
57
+ slope = (last[minor] - first[minor]).fdiv(last[major] - first[major])
58
+ major.zero? ? [step, (step * slope).truncate] : [(step * slope).truncate, step]
59
+ end
60
+
61
+ def axis_chord(first, crossing, major, step)
62
+ start = crossing.dup
63
+ finish = crossing.dup
64
+ start[1 - major] = finish[1 - major] = first[1 - major]
65
+ start[major] -= step
66
+ finish[major] += step
67
+ [start, finish]
68
+ end
69
+
70
+ # Integer seating must not extend the decorated carrier past an end.
71
+ def chord_fits?(first, last, point, radius)
72
+ chord(first, last, point, radius).all? do |seat|
73
+ [0, 1].all? { |axis| within?(seat[axis], first[axis], last[axis]) }
74
+ end
75
+ end
76
+
77
+ def dominant_axis(first, last)
78
+ (last[0] - first[0]).abs >= (last[1] - first[1]).abs ? 0 : 1
79
+ end
80
+
81
+ def axis_crossing(first, last, start, finish)
82
+ axis = axis_for(first, last)
83
+ return unless axis && axis_for(start, finish) == 1 - axis
84
+
85
+ axis.zero? ? [start[0], first[1]] : [first[0], start[1]]
86
+ end
87
+
88
+ def slanted_crossing(first, last, start, finish)
89
+ return if axis_for(first, last) && axis_for(start, finish)
90
+
91
+ direction = delta(first, last)
92
+ other = delta(start, finish)
93
+ denominator = cross(direction, other)
94
+ return if denominator.zero?
95
+
96
+ advance(first, direction, cross(delta(first, start), other).fdiv(denominator))
97
+ end
98
+
99
+ def advance(origin, direction, fraction)
100
+ [origin[0] + fraction * direction[0], origin[1] + fraction * direction[1]]
101
+ end
102
+
103
+ def delta(from, to)
104
+ [to[0] - from[0], to[1] - from[1]]
105
+ end
106
+
107
+ def cross(one, other)
108
+ one[0] * other[1] - one[1] * other[0]
109
+ end
110
+
111
+ def room?(point, first, last, radius)
112
+ distance(point, first) >= radius && distance(point, last) >= radius &&
113
+ [0, 1].all? { |axis| within?(point[axis], first[axis], last[axis]) }
114
+ end
115
+
116
+ def within?(value, from, to)
117
+ minimum, maximum = [from, to].minmax
118
+ value >= minimum - EPSILON && value <= maximum + EPSILON
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -33,6 +33,8 @@ module Ea
33
33
  autoload :Connectors, "ea/svg/ea_emitter/connectors"
34
34
  autoload :ConnectorBridges, "ea/svg/ea_emitter/connector_bridges"
35
35
  private_constant :ConnectorBridges
36
+ autoload :SegmentGeometry, "ea/svg/ea_emitter/segment_geometry"
37
+ private_constant :SegmentGeometry
36
38
  autoload :Markers, "ea/svg/ea_emitter/markers"
37
39
  autoload :Labels, "ea/svg/ea_emitter/labels"
38
40
  autoload :GhostLabels, "ea/svg/ea_emitter/ghost_labels"
data/lib/ea/version.rb CHANGED
@@ -4,5 +4,5 @@ module Ea
4
4
  # Gem release version. Extracted to its own file so the
5
5
  # `gem-release` tool's `gem bump` can find and update it via the
6
6
  # standard `lib/<gem>/version.rb` convention.
7
- VERSION = "0.6.138"
7
+ VERSION = "0.6.139"
8
8
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ea
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.138
4
+ version: 0.6.139
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -977,6 +977,7 @@ files:
977
977
  - lib/ea/svg/ea_emitter/markers.rb
978
978
  - lib/ea/svg/ea_emitter/orthogonal_ray.rb
979
979
  - lib/ea/svg/ea_emitter/render_context.rb
980
+ - lib/ea/svg/ea_emitter/segment_geometry.rb
980
981
  - lib/ea/svg/ea_emitter/style.rb
981
982
  - lib/ea/svg/ea_emitter/text_renderer.rb
982
983
  - lib/ea/svg/ea_emitter/visibility_symbol.rb