ea 0.6.131 → 0.6.133

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.
@@ -9,6 +9,8 @@ module Ea
9
9
  # SOID/EOID, EDGE, label boxes) when present. Falls back to
10
10
  # the UML-DI owned_element bounds otherwise.
11
11
  class DiagramBuilder
12
+ autoload :UmldiEdges, "ea/sources/xmi/diagram_builder/umldi_edges"
13
+
12
14
  attr_reader :root, :xmi_path
13
15
 
14
16
  def initialize(root, xmi_path = nil)
@@ -53,6 +55,7 @@ module Ea
53
55
  bounds: canvas_bounds(placed_elements),
54
56
  style: style_for(ext_diagram),
55
57
  style_ex: style_ex_for(ext_diagram),
58
+ show_notes: style_ex_for(ext_diagram).to_s.include?("ShowNotes=1"),
56
59
  elements: build_elements(placed_elements, id, umldi_keywords),
57
60
  connectors: build_connectors(placed_connectors,
58
61
  placed_elements, id),
@@ -144,20 +147,23 @@ module Ea
144
147
  acc[pe.style.duid] = pe if pe.style.duid
145
148
  end
146
149
 
147
- placed_connectors.map { |p| build_connector(p, by_duid, owner_id) }
150
+ placed_connectors.each_with_index.map do |placed, index|
151
+ build_connector(placed, by_duid, owner_id, index)
152
+ end
148
153
  end
149
154
 
150
- def build_connector(placed, by_duid, owner_id)
155
+ def build_connector(placed, by_duid, owner_id, rank)
151
156
  geom = placed.geometry
152
157
  style = placed.style
153
158
  source = by_duid[style.soid]
154
159
  target = by_duid[style.eoid]
155
- waypoints = compute_waypoints(geom, source, target)
156
160
  ext_meta = connector_meta(placed.subject)
161
+ route = connector_route(placed, source, target, owner_id, ext_meta[:ea_type])
157
162
  Ea::Model::DiagramConnector.new(
158
163
  id: IdNormalizer.synthetic_id(owner_id, "conn", placed.subject),
159
164
  diagram_id: owner_id,
160
165
  relationship_ref: placed.subject,
166
+ paint_order: [rank],
161
167
  source_duid: style.soid,
162
168
  target_duid: style.eoid,
163
169
  source_element_ref: source && synthetic_element_id(owner_id, source),
@@ -166,8 +172,8 @@ module Ea
166
172
  target_edge: geom.edge,
167
173
  connector_type: ext_meta[:ea_type],
168
174
  direction: ext_meta[:direction],
169
- waypoints: waypoints,
170
- style: {},
175
+ waypoints: route[:waypoints],
176
+ style: route[:style],
171
177
  line_color: style.color,
172
178
  line_width: style.line_width,
173
179
  hidden: style.hidden,
@@ -211,11 +217,35 @@ module Ea
211
217
  IdNormalizer.synthetic_id(owner_id, "elem", placed.subject)
212
218
  end
213
219
 
214
- def compute_waypoints(geom, source, target)
220
+ def connector_route(placed, source, target, owner_id, type)
221
+ native = umldi_waypoints(owner_id, placed) if placed.geometry.bend_points.to_a.empty?
222
+ points = native || compute_waypoints(placed.geometry, source, target,
223
+ style: placed.style, connector_type: type)
224
+ native ||= umldi_waypoints(owner_id, placed) if points.empty?
225
+ { waypoints: native || points, style: native ? { umldi_route: true } : {} }
226
+ end
227
+
228
+ # EA's unique exported edge supplies bendless routes and recovers
229
+ # routes whose placement endpoints cannot be resolved.
230
+ def umldi_waypoints(diagram_id, placed)
231
+ return unless xmi_path
232
+
233
+ route = umldi_edges.route_for(diagram_id, placed.subject)
234
+ route&.map { |x, y| Ea::Model::Waypoint.new(position: Ea::Model::Point.new(x: x, y: y)) }
235
+ end
236
+
237
+ def umldi_edges
238
+ @umldi_edges ||= UmldiEdges.new(xmi_path)
239
+ end
240
+
241
+ def compute_waypoints(geom, source, target, style:, connector_type:)
215
242
  src_bounds = bounds_from_placed(source)
216
243
  tgt_bounds = bounds_from_placed(target)
217
244
  return [] unless src_bounds && tgt_bounds
218
245
 
246
+ stored = stored_route(geom, style, connector_type, src_bounds, tgt_bounds)
247
+ return stored if stored
248
+
219
249
  router = Ea::Svg::ConnectorRouter.new(
220
250
  source_bounds: src_bounds,
221
251
  target_bounds: tgt_bounds,
@@ -229,6 +259,27 @@ module Ea
229
259
  end
230
260
  end
231
261
 
262
+ # Stored bends are routed by StoredRoute where a QEA rule claims
263
+ # them; anything else keeps the generic router.
264
+ def stored_route(geom, style, connector_type, src_bounds, tgt_bounds)
265
+ offsets = { source: [geom.sx, geom.sy], target: [geom.ex, geom.ey] }
266
+ points = StoredRoute.new.waypoints(
267
+ link: StoredRoute::Link.new(style.raw, geom.path.to_s.tr("$", ";"), offset_geometry(geom)),
268
+ connector_type: connector_type, source: src_bounds, target: tgt_bounds,
269
+ bends: geom.bend_points.to_a, offsets: offsets, edge: geom.edge
270
+ )
271
+ return unless points
272
+
273
+ points.map do |x, y|
274
+ Ea::Model::Waypoint.new(position: Ea::Model::Point.new(x: x, y: y))
275
+ end
276
+ end
277
+
278
+ def offset_geometry(geom)
279
+ { "SX" => geom.sx, "SY" => geom.sy, "EX" => geom.ex, "EY" => geom.ey }
280
+ .filter_map { |key, value| "#{key}=#{value};" if value }.join
281
+ end
282
+
232
283
  def bounds_from_placed(placed)
233
284
  return nil unless placed
234
285
 
@@ -264,12 +315,16 @@ module Ea
264
315
 
265
316
  label_boxes.transform_values do |box|
266
317
  {
267
- "cx" => box.cx, "cy" => box.cy,
268
318
  "ox" => box.ox, "oy" => box.oy,
319
+ "cx" => box.cx, "cy" => box.cy,
320
+ "hidden" => box.hidden,
269
321
  "bold" => box.bold,
270
322
  "italic" => box.italic,
271
323
  "underline" => box.underline,
272
- "align" => box.align
324
+ "color" => box.color,
325
+ "alignment" => box.align,
326
+ "direction" => box.direction,
327
+ "rotation" => box.rotation
273
328
  }
274
329
  end
275
330
  end
@@ -38,8 +38,9 @@ module Ea
38
38
  end
39
39
 
40
40
  # EA's Path= field uses the format `x1:y1$x2:y2$...` where
41
- # each pair is a bend point. Coordinates are absolute pixel
42
- # positions on the diagram canvas.
41
+ # each pair is a bend point. EA stores Y negated (as in QEA
42
+ # t_diagramlinks.Path); the result uses screen-positive Y,
43
+ # like element bounds.
43
44
  def parse_bend_path(raw)
44
45
  return [] if raw.nil? || raw.empty?
45
46
 
@@ -49,7 +50,7 @@ module Ea
49
50
  x_str, y_str = pair.split(":", 2)
50
51
  x = int(x_str)
51
52
  y = int(y_str)
52
- [x, y] if x && y
53
+ [x, -y] if x && y
53
54
  end
54
55
  end
55
56
 
@@ -72,7 +73,7 @@ module Ea
72
73
  end
73
74
 
74
75
  def extract_label_boxes(geometry_string)
75
- geometry_string.to_s.scan(/\$?([A-Z]{3})=((?:[A-Za-z]{1,4}=-?\d+:)+)/).each_with_object({}) do |(key, body), acc|
76
+ geometry_string.to_s.scan(/\$?([A-Z]{3})=((?:[A-Za-z]{1,4}=-?\d+:?)+)/).each_with_object({}) do |(key, body), acc|
76
77
  sym = key.downcase.to_sym
77
78
  next unless LABEL_BOX_KEYS.include?(sym)
78
79
  next if body.nil? || body.empty?
@@ -98,6 +99,7 @@ module Ea
98
99
  italic: hash["ITA"] == 1,
99
100
  underline: hash["UND"] == 1,
100
101
  hidden: hash["HDN"] == 1,
102
+ color: hash["CLR"],
101
103
  align: hash["ALN"],
102
104
  direction: hash["DIR"],
103
105
  rotation: hash["ROT"]
@@ -114,7 +116,7 @@ module Ea
114
116
 
115
117
  LabelBox = Struct.new(
116
118
  :cx, :cy, :ox, :oy,
117
- :bold, :italic, :underline, :hidden,
119
+ :bold, :italic, :underline, :hidden, :color,
118
120
  :align, :direction, :rotation,
119
121
  keyword_init: true
120
122
  )
@@ -49,7 +49,7 @@ module Ea
49
49
  id = IdNormalizer.from_xmi_id(element.id)
50
50
  {
51
51
  id: id,
52
- name: element.name || element.type&.split(":")&.last&.downcase,
52
+ name: element.name,
53
53
  qualified_name: element.name,
54
54
  annotations: AnnotationBuilder.from_element(element, id)
55
55
  }
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Sources
5
+ module Xmi
6
+ # Routes an XMI connector the way the QEA source does: the same
7
+ # center-ray ports and the same QEA route helpers, over the same
8
+ # inputs (link style, bends in `x:y;` form, connector type, endpoint
9
+ # boxes). Returns nil where QEA would fall back to bare SX/SY deltas,
10
+ # which the generic router keeps handling.
11
+ class StoredRoute < Qea::DiagramBuilder
12
+ Link = Struct.new(:style, :path, :geometry)
13
+ Connector = Struct.new(:connector_type)
14
+
15
+ def initialize
16
+ super(nil)
17
+ end
18
+
19
+ def waypoints(link:, connector_type:, source:, target:, bends:, offsets:, edge: nil)
20
+ connector = Connector.new(connector_type)
21
+ ports = { source: port_for(source, offsets[:source]), target: port_for(target, offsets[:target]) }
22
+ edge ||= 0
23
+ points = initial_points(link, connector, source, target, bends, ports, edge)
24
+ return unless points
25
+
26
+ finish(points, link, connector, source, target, bends, ports, offsets, edge)
27
+ end
28
+
29
+ private
30
+
31
+ # Placements here are already Ea::Model::Bounds.
32
+ def bounds_from_rect(bounds)
33
+ bounds
34
+ end
35
+
36
+ def ported?(offset)
37
+ offset.none?(&:nil?) && offset.any?(&:nonzero?)
38
+ end
39
+
40
+ def port_for(box, offset)
41
+ ray_port(box, offset) if ported?(offset)
42
+ end
43
+
44
+ def initial_points(link, connector, source, target, bends, ports, edge)
45
+ src = ports[:source] || element_edge_point(source, :source, edge)
46
+ tgt = ports[:target] || element_edge_point(target, :target, edge)
47
+ if Qea::DiagramStyleParser.parse(link.style)[:mode] == "1"
48
+ [src, tgt]
49
+ elsif edge == 2 && ports.values.all? && !valid_tree_path?(bends, source)
50
+ [src, [tgt[0], src[1]], tgt]
51
+ elsif edge == 2 && bends.size == 1
52
+ tree_points(source, target, bends.first)
53
+ elsif bends.any?
54
+ bent_points(src, bends, tgt, link, connector, source, target)
55
+ elsif ports.values.any?
56
+ [src, tgt]
57
+ end
58
+ end
59
+
60
+ def tree_points(src, tgt, bend)
61
+ target_y = tgt.y + tgt.height <= src.y ? tgt.y + tgt.height : tgt.y
62
+ [[src.x + src.width, bend[1]], [bend[0], bend[1]], [bend[0], target_y]]
63
+ end
64
+
65
+ def bent_points(src, bends, tgt, link, connector, source, target)
66
+ points = [src, *bends, tgt]
67
+ redock_parent_above(points, link, source, target) if connector.connector_type == "Generalization"
68
+ points
69
+ end
70
+
71
+ def finish(points, link, connector, source, target, bends, ports, offsets, edge)
72
+ tree_route = stored_tree_route(points, link, connector, source, target, ports)
73
+ if tree_route
74
+ points = tree_route
75
+ elsif %w[Association Aggregation].include?(connector.connector_type) && edge != 2
76
+ points = facing(points, link, connector, source, target, bends)
77
+ end
78
+ points = level_entry_route(points, link, connector, source, target) if bends.any? && !tree_route
79
+ points = drop_zero_length_tree_segments(points, link)
80
+ empty_path_ray(points, link, connector, source, target, bends, ports, offsets, edge)
81
+ end
82
+
83
+ # An empty Path has no authored corner to keep: a computed EDGE2 jog
84
+ # between ports that face across a gap becomes the straight port line.
85
+ def empty_path_ray(points, link, connector, source, target, bends, ports, offsets, edge)
86
+ style = Qea::DiagramStyleParser.parse(link.style)
87
+ return points unless %w[Generalization Aggregation].include?(connector.connector_type) &&
88
+ points.size == 3 && bends.empty? && edge == 2 && ports.values.all? &&
89
+ style[:mode] == "3" && style[:lwidth] == "2" && !style[:tree]
90
+ return points unless ray_gap?(source, target, offsets)
91
+
92
+ ports.values_at(:source, :target)
93
+ end
94
+
95
+ def ray_gap?(source, target, offsets)
96
+ return false unless source.x + source.width < target.x
97
+
98
+ [[source, offsets[:source]], [target, offsets[:target]]].all? do |box, (x, y)|
99
+ within_height?(stored_ray_origin(box, x, y).y, source) &&
100
+ within_height?(stored_ray_origin(box, x, y).y, target)
101
+ end
102
+ end
103
+
104
+ def stored_tree_route(points, link, connector, source, target, ports)
105
+ stored_square_route(link, connector, source, target) ||
106
+ stored_stair_route(link, connector, source, target) ||
107
+ stored_lateral_route(link, connector, source, target, ported: ports.values.all?) ||
108
+ derived_lateral_route(link, connector, source, target) ||
109
+ horizontal_tree_route(points, link, connector, source, target)
110
+ end
111
+
112
+ def facing(points, link, connector, source, target, bends)
113
+ stored_tree = preserve_stored_tree?(points, link, connector, source, target)
114
+ unless stored_tree || level_corner_route?(points, link, connector, source, target)
115
+ points = facing_edge_route(points, source, target)
116
+ end
117
+ if connector.connector_type == "Aggregation" && bends.any? && points.size >= 4 &&
118
+ (generalization_outline_offset(link, points.size) == 1 ||
119
+ (stored_tree && flush_width_zero_tree?(link)))
120
+ redock_parent_above(points, link, source, target, strict: true)
121
+ end
122
+ redock_vertical_tree_trunk(points, link, connector, source, target) if bends.any?
123
+ points
124
+ end
125
+
126
+ # EA's SX/SY (EX/EY) point from the box center toward the port,
127
+ # y up; the port is where that ray leaves the box.
128
+ def ray_port(bounds, offset)
129
+ dx = offset[0].to_f
130
+ dy = -offset[1].to_f
131
+ center_x = bounds.x + bounds.width / 2.0
132
+ center_y = bounds.y + bounds.height / 2.0
133
+ t = [ray_scale(dx, bounds.x, bounds.width, center_x),
134
+ ray_scale(dy, bounds.y, bounds.height, center_y)].min
135
+ [(center_x + t * dx).round, (center_y + t * dy).round]
136
+ end
137
+
138
+ def ray_scale(delta, origin, length, center)
139
+ return Float::INFINITY if delta.zero?
140
+
141
+ ((delta.positive? ? origin + length : origin) - center) / delta
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -28,6 +28,7 @@ module Ea
28
28
  autoload :NoteBuilder, "ea/sources/xmi/note_builder"
29
29
  autoload :StereotypeBuilder, "ea/sources/xmi/stereotype_builder"
30
30
  autoload :DiagramBuilder, "ea/sources/xmi/diagram_builder"
31
+ autoload :StoredRoute, "ea/sources/xmi/stored_route"
31
32
  autoload :ExtensionTypes, "ea/sources/xmi/extension_types"
32
33
  autoload :ExtensionElements, "ea/sources/xmi/extension_elements"
33
34
  autoload :ExtensionGeometryParser, "ea/sources/xmi/extension_geometry_parser"
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Svg
5
+ module EaEmitter
6
+ # Bridges decorate resolved render paths; stored routes and marker seats
7
+ # remain centerlines. The connector's stored order key (reproducing EA's
8
+ # exported link order), rather than style group order, determines which
9
+ # connector passes over a crossing.
10
+ class ConnectorBridges
11
+ RADIUS = 6
12
+
13
+ def initialize(_diagram, routes)
14
+ @routes = routes.map { |connector, points| [connector, points, priority_for(connector)] }
15
+ end
16
+
17
+ def path_for(connector, points)
18
+ priority = priority_for(connector)
19
+ return centerline(connector, points) unless priority
20
+
21
+ segments = points.each_cons(2).map do |first, last|
22
+ crossings_for(connector, priority, first, last)
23
+ end
24
+ return centerline(connector, points) if segments.all?(&:empty?)
25
+
26
+ serialize(points, segments)
27
+ end
28
+
29
+ private
30
+
31
+ def centerline(connector, points)
32
+ seats = points.map { |point| coordinates(point) }
33
+ # Direct lines retain EA's target-first repeat and source-first model.
34
+ if points.size == 2 && direct?(connector)
35
+ "M #{seats.last} L #{seats.first} M #{seats.last}"
36
+ else
37
+ "M #{seats.join(" L ")}"
38
+ end
39
+ end
40
+
41
+ def direct?(connector)
42
+ style = connector.style || {}
43
+ style.key?(:direct) || style.key?("direct")
44
+ end
45
+
46
+ def serialize(points, segments)
47
+ commands = ["M #{coordinates(points.first)}"]
48
+ points.each_cons(2).with_index do |(first, last), index|
49
+ segments[index].each do |crossing|
50
+ commands.concat(arc_commands(first, last, crossing))
51
+ end
52
+ commands << "L #{coordinates(last)}"
53
+ end
54
+ commands.join(" ")
55
+ end
56
+
57
+ # nil or empty: no bridge decision for this connector.
58
+ def priority_for(connector)
59
+ connector.paint_order if connector.paint_order&.any?
60
+ end
61
+
62
+ def crossings_for(connector, priority, first, last)
63
+ axis = axis_for(first, last)
64
+ return [] unless axis
65
+
66
+ crossings = lower_routes(connector, priority).flat_map do |_partner, points, _order|
67
+ points.each_cons(2).filter_map do |start, finish|
68
+ crossing(first, last, start, finish, axis)
69
+ end
70
+ end
71
+ separate_crossings(crossings, axis).sort_by { |point| (point[axis] - first[axis]).abs }
72
+ end
73
+
74
+ # The order key is only proven across different start placements, so
75
+ # connectors leaving the same placement make no carrier decision.
76
+ def lower_routes(connector, priority)
77
+ @routes.select do |partner, _points, order|
78
+ !partner.equal?(connector) && order && order.size == priority.size &&
79
+ !same_source?(connector, partner) && (order <=> priority).negative?
80
+ end
81
+ end
82
+
83
+ def same_source?(connector, partner)
84
+ connector.source_element_ref && connector.source_element_ref == partner.source_element_ref
85
+ end
86
+
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
94
+ end
95
+ end
96
+
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])
109
+ end
110
+
111
+ def interior?(value, first, last)
112
+ minimum, maximum = [first, last].minmax
113
+ value > minimum && value < maximum &&
114
+ value - minimum >= RADIUS && maximum - value >= RADIUS
115
+ end
116
+
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
122
+ end
123
+
124
+ 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
131
+ ["L #{coordinates(start)}", "A #{RADIUS} #{RADIUS} 180 0 0 #{coordinates(finish)}"]
132
+ end
133
+
134
+ def coordinates(point)
135
+ point.map { |value| Canvas.coord(value) }.join(" ")
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -16,9 +16,10 @@ module Ea
16
16
  # Returns replacement waypoint pairs for direct or lateral tree routes,
17
17
  # for the connector, or nil when the connector must keep its
18
18
  # parsed waypoints (not direct, not 2-point, unresolved ends,
19
- # self-loop, or no drawn bounds for either end).
19
+ # self-loop, an authoritative UMLDI route, or no drawn bounds for either end).
20
20
  def pairs_for(connector, diagram, model_index, bounds_map)
21
21
  return nil unless bounds_map
22
+ return nil if connector.style.to_h[:umldi_route] == true
22
23
 
23
24
  tether = association_class_pairs(connector, bounds_map)
24
25
  return tether if tether
@@ -53,8 +54,8 @@ module Ea
53
54
 
54
55
  return nil if flush_lateral_line?(connector, points, source_bounds, target_bounds)
55
56
 
56
- anchors = [connector.source_anchor, connector.target_anchor]
57
57
  bounds = [source_bounds, target_bounds]
58
+ anchors = drawn_center_anchors(connector, diagram, [source_ref, target_ref], bounds)
58
59
  original = clipped_pairs(bounds, anchors)
59
60
  unless stroke_outline?(connector, bounds) || overlap_ray?(connector) ||
60
61
  bordered_outline?(connector, diagram, [source_ref, target_ref], bounds)
@@ -78,6 +79,38 @@ module Ea
78
79
  auto_port_elbow_pairs(connector, source, target, pairs)
79
80
  end
80
81
 
82
+ def drawn_center_anchors(connector, diagram, refs, bounds)
83
+ anchors = [connector.source_anchor, connector.target_anchor]
84
+ return anchors unless connector.style.to_h[:centered_origin_ray] == true
85
+
86
+ duids = connector.style.to_h.values_at(:soid, :eoid)
87
+ anchors.each_with_index.map do |anchor, index|
88
+ saved = unique_logical_bounds(diagram, refs[index], duids[index])
89
+ centered_anchor(anchor, saved, bounds[index])
90
+ end
91
+ end
92
+ private_class_method :drawn_center_anchors
93
+
94
+ def unique_logical_bounds(diagram, ref, duid)
95
+ placements = end_placements(diagram, ref, duid)
96
+ placements.first.bounds if placements.size == 1
97
+ end
98
+ private_class_method :unique_logical_bounds
99
+
100
+ def centered_anchor(anchor, saved, drawn)
101
+ return anchor unless anchor && saved
102
+
103
+ Ea::Model::Point.new(x: anchor.x + center_shift(saved, drawn, :x, :width),
104
+ y: anchor.y + center_shift(saved, drawn, :y, :height))
105
+ end
106
+ private_class_method :centered_anchor
107
+
108
+ def center_shift(saved, drawn, coordinate, size)
109
+ drawn.public_send(coordinate) + drawn.public_send(size) / 2 -
110
+ saved.public_send(coordinate) - saved.public_send(size) / 2
111
+ end
112
+ private_class_method :center_shift
113
+
81
114
  # A tether joins its saved association anchor to the facing
82
115
  # side-center of the attached class, rather than to the other proxy.
83
116
  def association_class_pairs(connector, bounds_map)
@@ -475,6 +508,7 @@ module Ea
475
508
  def row_slot_adjust(pairs, connector, diagram, model_index,
476
509
  bounds_map, divider_y_by_ref)
477
510
  return nil unless bounds_map && divider_y_by_ref
511
+ return nil if connector.style.to_h[:umldi_route] == true
478
512
  return nil unless pairs.size == 2
479
513
  return nil if flush_overlap_ray?(connector)
480
514
  return nil if explicit_association_ray?(pairs, connector)
@@ -74,11 +74,14 @@ module Ea
74
74
  private
75
75
 
76
76
  def paths_with_styles
77
- visible_connectors.filter_map do |connector|
78
- path = path_for(connector)
79
- next unless path
80
-
81
- [[self.class.stroke_width_for(connector, stroke_width), dashed?(connector)], path]
77
+ routes = visible_connectors.filter_map do |connector|
78
+ points = resolved_points(connector)
79
+ [connector, points.map { |point| translate_point(point) }] if points.size >= 2
80
+ end
81
+ bridges = ConnectorBridges.new(diagram, routes)
82
+ routes.map do |connector, points|
83
+ [[self.class.stroke_width_for(connector, stroke_width), dashed?(connector)],
84
+ path_for(connector, points, bridges)]
82
85
  end
83
86
  end
84
87
 
@@ -103,39 +106,17 @@ module Ea
103
106
  (diagram.connectors || []).select(&:renderable?)
104
107
  end
105
108
 
106
- def path_for(connector)
109
+ def resolved_points(connector)
107
110
  pts = ConnectorRedock.pairs_for(connector, diagram, model_index,
108
111
  drawn_bounds) ||
109
112
  waypoints_for(connector)
110
- pts = ConnectorRedock.row_slot_adjust(pts, connector, diagram,
111
- model_index, drawn_bounds,
112
- divider_y_by_ref) || pts
113
- return nil if pts.size < 2
114
-
115
- # DIRECT (auto-routed straight) connectors: EA writes the
116
- # segment TARGET-FIRST with a trailing zero-length repeat —
117
- # "M target L source M target" (B6847577, E58034A3,
118
- # 8CF27BE8). Same pixels as source-first; only the byte
119
- # order differs, so reversal lives here at the emitter and
120
- # the marker anchoring (source = first waypoint) is
121
- # untouched.
122
- if pts.size == 2 && direct?(connector)
123
- x1, y1 = translate_point(pts.first)
124
- x2, y2 = translate_point(pts.last)
125
- d = "M #{Canvas.coord(x2)} #{Canvas.coord(y2)} L #{Canvas.coord(x1)} #{Canvas.coord(y1)} " \
126
- "M #{Canvas.coord(x2)} #{Canvas.coord(y2)}"
127
- else
128
- d = pts.each_with_index.map do |p, idx|
129
- x, y = translate_point(p)
130
- "#{idx.zero? ? 'M' : 'L'} #{Canvas.coord(x)} #{Canvas.coord(y)}"
131
- end.join(" ")
132
- end
133
- %(<path d="#{d}" shape-rendering="auto"/>)
113
+ ConnectorRedock.row_slot_adjust(pts, connector, diagram,
114
+ model_index, drawn_bounds,
115
+ divider_y_by_ref) || pts
134
116
  end
135
117
 
136
- def direct?(connector)
137
- style = connector.style || {}
138
- style.key?(:direct) || style.key?("direct")
118
+ def path_for(connector, pts, bridges)
119
+ %(<path d="#{bridges.path_for(connector, pts)}" shape-rendering="auto"/>)
139
120
  end
140
121
 
141
122
  def waypoints_for(connector)
@@ -146,10 +127,10 @@ module Ea
146
127
  end
147
128
  end
148
129
 
149
- def translate_point(p)
150
- return p unless @canvas
130
+ def translate_point(point)
131
+ return point unless @canvas
151
132
 
152
- [@canvas.translate_x(p[0]), @canvas.translate_y(p[1])]
133
+ [@canvas.translate_x(point[0]), @canvas.translate_y(point[1])]
153
134
  end
154
135
  end
155
136
  end