ea 0.6.136 → 0.6.138

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: 121e254bdf4c232789483be3fc0de9f65e0b70a89c34009db6421d9dcae1337d
4
- data.tar.gz: 1f11075a36fe5f7208b264f4f10e409230afe184101157ba41a3bf05fd57efb6
3
+ metadata.gz: 0c2231b8d009e8d99c841920776cbe8539e09b76d512beed109f073fb7904282
4
+ data.tar.gz: 3feade927368b299842047c9c0e31aa0cb8473e7dad6870e5626fb369e1503de
5
5
  SHA512:
6
- metadata.gz: f924fea92342d3525a5fb5ea6b1b8c0167afc5f709d1c373a52b178a263efa29c9adc8bf690c4fba1073053a2f75c5888abbd509dcd187c39a57a0ab4c089286
7
- data.tar.gz: 5c558dcefad07838fd403fa8eae5a9e7bf00e802348de364d28adb692e02bb76a6de2909139570431bb96a364955c276a5a356b05ba5107ce6906f0aa03315a5
6
+ metadata.gz: 025f36b456316a6fcd801318f17c27363d62d3ee7355cd303c1b7147081f44d3b70fdf711966ae4a5a7fc33e022dc097ba4d6f15f6c3ad03e237d59318b89fac
7
+ data.tar.gz: d2487edce57559735c9cdf42984dd16ad3597985fb5d2f01ddeb9289c893b423a986f8587b3788d0cd759aaca93661c8e0a86262a4939f503d21e9495e29db9f
@@ -46,6 +46,7 @@
46
46
  "Z": 5,
47
47
  "[": 4,
48
48
  "]": 4,
49
+ "_": 4,
49
50
  "a": 5,
50
51
  "b": 5,
51
52
  "c": 4,
@@ -38,6 +38,9 @@ module Ea
38
38
  attribute :hidden, :boolean
39
39
  attribute :is_substitutable, :boolean, default: false
40
40
  attribute :label_boxes, :hash, default: -> { {} }
41
+ # Absolute labels saved by UML-DI when the EA extension omits the
42
+ # corresponding relative label boxes.
43
+ attribute :umldi_label_bounds, :hash, default: -> { {} }
41
44
  attribute :ghost_labels, GhostLabel, collection: true, initialize_empty: true
42
45
  # True when the t_diagramlinks.Geometry string carries explicit
43
46
  # SX/SY/EX/EY offset fields. EA renders the «import» package_anchor
@@ -86,6 +89,7 @@ module Ea
86
89
  map "lineWidth", to: :line_width
87
90
  map "hidden", to: :hidden, render_default: true
88
91
  map "labelBoxes", to: :label_boxes
92
+ map "umldiLabelBounds", to: :umldi_label_bounds
89
93
  map "ghostLabels", to: :ghost_labels, render_empty: true
90
94
  map "hasGeometryOffsets", to: :has_geometry_offsets, render_default: true
91
95
  end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Ea
6
+ module Sources
7
+ module Xmi
8
+ class DiagramBuilder
9
+ # EA stores the association-class reference, connector subtype and proxy
10
+ # classifiers in extension attributes omitted by the XMI model gem.
11
+ class AssociationClassMetadata
12
+ def initialize(xmi_path)
13
+ @xmi_path = xmi_path
14
+ end
15
+
16
+ def for(connector, placed_subjects:, waypoints:)
17
+ return unless valid_tether?(connector, waypoints)
18
+
19
+ attached_id = connector_data.dig(connector.idref, :association_class)
20
+ return unless attached_id && class_ids.include?(attached_id)
21
+ return unless placed_subjects.any? { |subject| guid_key(subject) == guid_key(attached_id) }
22
+
23
+ attached_id
24
+ end
25
+
26
+ private
27
+
28
+ def valid_tether?(connector, waypoints)
29
+ @xmi_path && connector && association_class?(connector) &&
30
+ proxy_pair?(connector) && coincident_route?(waypoints)
31
+ end
32
+
33
+ def association_class?(connector)
34
+ connector.properties&.ea_type == "Association" &&
35
+ connector_data.dig(connector.idref, :subtype) == "Class" &&
36
+ connector_data.dig(connector.idref, :private_data).to_s.match?(/\A[1-9]\d*\z/)
37
+ end
38
+
39
+ def proxy_pair?(connector)
40
+ endpoints = [connector.source, connector.target]
41
+ return false unless proxy_endpoints?(endpoints)
42
+
43
+ classifiers = endpoints.map { |endpoint| proxy_classifiers[endpoint.idref] }
44
+ matching_classifiers?(classifiers)
45
+ end
46
+
47
+ def proxy_endpoints?(endpoints)
48
+ endpoints.all? { |endpoint| endpoint&.model&.type == "ProxyConnector" }
49
+ end
50
+
51
+ def matching_classifiers?(classifiers)
52
+ classifiers.none? { |classifier| classifier.to_s.empty? } && classifiers.uniq.one?
53
+ end
54
+
55
+ def coincident_route?(waypoints)
56
+ first = waypoints&.first&.position
57
+ last = waypoints&.last&.position
58
+ first && last && first.x == last.x && first.y == last.y
59
+ end
60
+
61
+ def connector_data
62
+ @connector_data ||= read_metadata.fetch(:connectors)
63
+ end
64
+
65
+ def proxy_classifiers
66
+ @proxy_classifiers ||= read_metadata.fetch(:proxies)
67
+ end
68
+
69
+ def class_ids
70
+ @class_ids ||= read_metadata.fetch(:classes)
71
+ end
72
+
73
+ def read_metadata
74
+ @read_metadata ||= scan_metadata
75
+ end
76
+
77
+ def scan_metadata
78
+ data = { connectors: {}, proxies: {}, classes: [] }
79
+ File.open(@xmi_path) do |file|
80
+ Nokogiri::XML::Reader(file).each { |node| read_node(node, data) }
81
+ end
82
+ data
83
+ end
84
+
85
+ def read_node(node, data)
86
+ return unless node.node_type == Nokogiri::XML::Reader::TYPE_ELEMENT
87
+
88
+ read_element(node, data) if node.local_name == "element"
89
+ read_connector(node, data) if node.local_name == "connector"
90
+ end
91
+
92
+ def read_element(node, data)
93
+ element = Nokogiri::XML(node.outer_xml).root
94
+ id = attr(element, "idref")
95
+ return unless id
96
+
97
+ type = attr(element, "type")
98
+ data[:classes] << id if type == "uml:Class"
99
+ data[:proxies][id] = attr(element, "classifier") if type == "uml:ProxyConnector"
100
+ end
101
+
102
+ def read_connector(node, data)
103
+ connector = Nokogiri::XML(node.outer_xml).root
104
+ id = attr(connector, "idref")
105
+ return unless id
106
+
107
+ extended = connector.at_xpath("./*[local-name()='extendedProperties']")
108
+ properties = connector.at_xpath("./*[local-name()='properties']")
109
+ data[:connectors][id] = {
110
+ subtype: attr(properties, "subtype"),
111
+ association_class: attr(extended, "associationclass"),
112
+ private_data: attr(extended, "privatedata1")
113
+ }
114
+ end
115
+
116
+ def attr(node, name)
117
+ node&.attribute_nodes&.find { |attribute| attribute.name == name }&.value
118
+ end
119
+
120
+ def guid_key(subject)
121
+ subject&.sub(/\AEA(?:ID|PK)_/, "")&.upcase
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Sources
5
+ module Xmi
6
+ class DiagramBuilder
7
+ class UmldiEdges
8
+ # Association membership proves which saved role bounds an edge owns.
9
+ class PropertyRoles
10
+ def initialize(doc)
11
+ @doc = doc
12
+ end
13
+
14
+ def keys_for(diagram_id, connector_id)
15
+ property_roles.dig(diagram_id, connector_id) || []
16
+ end
17
+
18
+ private
19
+
20
+ def property_roles
21
+ @property_roles ||= begin
22
+ document = @doc
23
+ declared = declared_properties(document)
24
+ document.xpath("//*[local-name()=\"Diagram\"]").to_h do |diagram|
25
+ [diagram["id"] || diagram["xmi:id"], diagram_property_roles(diagram, declared)]
26
+ end
27
+ end
28
+ end
29
+
30
+ def declared_properties(document)
31
+ members = document.xpath("//*[local-name()='memberEnd']").to_set do |node|
32
+ [node["xmi:idref"], node.parent["xmi:id"]]
33
+ end
34
+ document.xpath("//*[@association][@xmi:id]", "xmi" => "http://www.omg.org/spec/XMI/20131001")
35
+ .filter_map do |node|
36
+ pair = [node["xmi:id"], node["association"]]
37
+ pair if node.attributes["type"]&.value == "uml:Property" && members.include?(pair)
38
+ end.to_set
39
+ end
40
+
41
+ def diagram_property_roles(diagram, declared)
42
+ unique_edges(diagram).transform_values do |edge|
43
+ edge.xpath("./ownedElement[@modelElement]").filter_map do |label|
44
+ key = role_key(label)
45
+ key if key &&
46
+ declared.include?([label["modelElement"], edge["modelElement"]])
47
+ end
48
+ end
49
+ end
50
+
51
+ def unique_edges(diagram)
52
+ diagram.xpath("./ownedElement[@modelElement]")
53
+ .select { |node| node.attributes["type"]&.value.to_s.end_with?("UMLEdge") }
54
+ .group_by { |edge| edge["modelElement"] }
55
+ .select { |_, edges| edges.one? }.transform_values(&:first)
56
+ end
57
+
58
+ def role_key(label)
59
+ return unless label.attributes["type"]&.value.to_s.end_with?("UMLAssociationEndLabel")
60
+
61
+ case label["xmi:id"] || label["id"]
62
+ when /\AEAID_SEL/ then :source_role
63
+ when /\AEAID_TEL/ then :target_role
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -11,6 +11,8 @@ module Ea
11
11
  # drawn by more than one edge in the same diagram has neither; an edge
12
12
  # with fewer than two waypoints has no route.
13
13
  class UmldiEdges
14
+ autoload :PropertyRoles, "ea/sources/xmi/diagram_builder/umldi_edges/property_roles"
15
+
14
16
  def initialize(xmi_path)
15
17
  @xmi_path = xmi_path
16
18
  end
@@ -23,6 +25,20 @@ module Ea
23
25
  ends.dig(diagram_id, connector_id)
24
26
  end
25
27
 
28
+ # Absolute saved UMLDI bounds for connector label boxes. These are
29
+ # kept separate from extension label offsets: callers may use them
30
+ # when extension geometry is absent or the association owns the role.
31
+ def label_bounds_for(diagram_id, connector_id)
32
+ label_bounds.dig(diagram_id, connector_id)
33
+ end
34
+
35
+ # Role label keys whose UMLDI label is bound to the property that
36
+ # the connector's association declares, so the bounds describe that
37
+ # end's own role label rather than an unrelated label.
38
+ def property_role_keys_for(diagram_id, connector_id)
39
+ (@property_roles ||= PropertyRoles.new(doc)).keys_for(diagram_id, connector_id)
40
+ end
41
+
26
42
  private
27
43
 
28
44
  def ends
@@ -31,6 +47,46 @@ module Ea
31
47
  end
32
48
  end
33
49
 
50
+ def label_bounds
51
+ @label_bounds ||= doc.xpath("//*[local-name()=\"Diagram\"]").to_h do |diagram|
52
+ [diagram["id"] || diagram["xmi:id"], diagram_label_bounds(diagram)]
53
+ end
54
+ end
55
+
56
+ def diagram_label_bounds(diagram)
57
+ boxes_by_connector = unique_edges(diagram).to_h do |connector_id, edge|
58
+ [connector_id, edge_label_bounds(edge)]
59
+ end
60
+ boxes_by_connector.reject { |_, boxes| boxes.empty? }
61
+ end
62
+
63
+ def edge_label_bounds(edge)
64
+ edge.xpath("./ownedElement[@modelElement]").each_with_object({}) do |label, found|
65
+ bounds = label.at_xpath("./*[local-name()=\"bounds\"]")
66
+ key = label_key(label)
67
+ next unless key && bounds
68
+
69
+ found[key] = bounds_values(bounds)
70
+ rescue ArgumentError, TypeError
71
+ next
72
+ end
73
+ end
74
+
75
+ def bounds_values(bounds)
76
+ %w[x y width height].to_h { |attribute| [attribute.to_sym, Integer(bounds[attribute])] }
77
+ end
78
+
79
+ def label_key(label)
80
+ type = label.attributes["type"]&.value.to_s
81
+ role = (label["id"] || label["xmi:id"]).to_s
82
+ end_name = { "EAID_SEL" => "source", "EAID_SML" => "source",
83
+ "EAID_TEL" => "target", "EAID_TML" => "target" }
84
+ side = end_name.find { |prefix, _| role.start_with?(prefix) }&.last
85
+ suffix = { "UMLAssociationEndLabel" => "role", "UMLMultiplicityLabel" => "multiplicity" }
86
+ label_kind = suffix[type.split(":").last]
87
+ "#{side}_#{label_kind}".to_sym if side && label_kind
88
+ end
89
+
34
90
  def diagram_ends(diagram)
35
91
  unique_edges(diagram).each_with_object({}) do |(connector_id, edge), acc|
36
92
  acc[connector_id] = [edge["source"], edge["target"]] if edge["source"] && edge["target"]
@@ -10,6 +10,7 @@ module Ea
10
10
  # the UML-DI owned_element bounds otherwise.
11
11
  class DiagramBuilder
12
12
  autoload :ConnectorMetadata, "ea/sources/xmi/diagram_builder/connector_metadata"
13
+ autoload :AssociationClassMetadata, "ea/sources/xmi/diagram_builder/association_class_metadata"
13
14
  autoload :FontSettings, "ea/sources/xmi/diagram_builder/font_settings"
14
15
  autoload :StereotypeProfiles, "ea/sources/xmi/diagram_builder/stereotype_profiles"
15
16
  autoload :UmldiEdges, "ea/sources/xmi/diagram_builder/umldi_edges"
@@ -44,11 +45,12 @@ module Ea
44
45
 
45
46
  def build_one(ext_diagram)
46
47
  id = IdNormalizer.from_xmi_id(ext_diagram.id)
47
- ext_elements = ExtensionElements.new(ext_diagram)
48
+ ext_elements = ExtensionElements.new(ext_diagram, connector_subjects: connector_index)
48
49
  placed_elements = ext_elements.placed_elements
49
50
  placed_connectors = ext_elements.placed_connectors
50
51
  props = ext_diagram.properties
51
52
  umldi_keywords = keywords_for(id)
53
+ context = context_diagram?(ext_diagram)
52
54
 
53
55
  Ea::Model::Diagram.new(
54
56
  id: id,
@@ -64,8 +66,7 @@ module Ea
64
66
  show_parents: !style_flag?(ext_diagram, "HideParents"),
65
67
  hand_draw: style_flag?(ext_diagram, "HandDraw"),
66
68
  elements: build_elements(placed_elements, id, umldi_keywords),
67
- connectors: build_connectors(placed_connectors,
68
- placed_elements, id),
69
+ connectors: build_connectors(placed_connectors, placed_elements, id, context),
69
70
  annotations: []
70
71
  )
71
72
  end
@@ -101,6 +102,13 @@ module Ea
101
102
  value.to_i.nonzero? ? true : false
102
103
  end
103
104
 
105
+ def context_diagram?(ext_diagram)
106
+ name = ext_diagram.properties&.name.to_s
107
+ return true if name.match?(/\AContext Diagram: Package (Clients|Suppliers) of /i)
108
+
109
+ style_ex_for(ext_diagram).to_s.match(/(?:\A|;)SuppressFOC=(\d)/)&.public_send(:[], 1) != "1"
110
+ end
111
+
104
112
  def page_bounds(ext_diagram)
105
113
  style = style_for(ext_diagram).to_s
106
114
  width = style[/(?:\A|;)DocSize\.cx=(\d+)/, 1]
@@ -130,6 +138,7 @@ module Ea
130
138
  line_width: style.line_width,
131
139
  font_family: font[:family],
132
140
  font_size: font[:size],
141
+ font_size_unit: "pt",
133
142
  font_logical_height: font[:logical_height],
134
143
  font_size_inherited: ExtensionStyleParser.split_pairs(style.raw.to_s)["fontsz"] == "0",
135
144
  font_bold: style.bold,
@@ -140,10 +149,32 @@ module Ea
140
149
  z_order: placed.seqno,
141
150
  duid: style.duid,
142
151
  umldi_keyword: umldi_keywords[placed.subject],
143
- style: (@stereotype_profiles ||= StereotypeProfiles.new(root)).for(placed.subject)
152
+ style: element_style(placed.subject)
144
153
  )
145
154
  end
146
155
 
156
+ # XMI preserves UML member order in ownedAttribute. Carry that
157
+ # provenance to the renderer so it does not replace it with a name sort.
158
+ def element_style(subject)
159
+ style = (@stereotype_profiles ||= StereotypeProfiles.new(root)).for(subject)
160
+ return style unless source_ordered_classifiers.include?(subject)
161
+
162
+ style.merge(stored_attribute_order: true)
163
+ end
164
+
165
+ def source_ordered_classifiers
166
+ @source_ordered_classifiers ||= begin
167
+ ids = []
168
+ visit = lambda do |element|
169
+ ids << element.id if TypeClassifierMap.classifier_type?(element.type)
170
+ element.packaged_element.to_a.each { |child| visit.call(child) }
171
+ element.nested_classifier.to_a.each { |child| visit.call(child) }
172
+ end
173
+ root.model&.packaged_element.to_a.each { |element| visit.call(element) }
174
+ ids
175
+ end
176
+ end
177
+
147
178
  def build_bounds(left, top, right, bottom)
148
179
  return nil unless left && top && right && bottom
149
180
 
@@ -152,7 +183,7 @@ module Ea
152
183
  height: bottom - top)
153
184
  end
154
185
 
155
- def build_connectors(placed_connectors, placed_elements, owner_id)
186
+ def build_connectors(placed_connectors, placed_elements, owner_id, context)
156
187
  # Index elements by DUID so we can resolve SOID/EOID.
157
188
  by_duid = placed_elements.each_with_object({}) do |pe, acc|
158
189
  acc[pe.style.duid] = pe if pe.style.duid
@@ -160,23 +191,46 @@ module Ea
160
191
  by_subject = placed_elements.group_by { |pe| guid_key(pe.subject) }
161
192
 
162
193
  placed_connectors.each_with_index.map do |placed, index|
163
- build_connector(placed, by_duid, by_subject, owner_id, index)
194
+ connector = build_connector(placed, by_duid, by_subject, owner_id, rank: index, context: context)
195
+ mark_association_class(connector, placed, placed_elements, context)
164
196
  end
165
197
  end
166
198
 
167
- def build_connector(placed, by_duid, by_subject, owner_id, rank)
199
+ def mark_association_class(connector, placed, placed_elements, context)
200
+ mark_plain_end_seats(connector, placed, context)
201
+ reference = association_class_metadata.for(
202
+ connector_index[placed.subject],
203
+ placed_subjects: placed_elements.map(&:subject),
204
+ waypoints: connector.waypoints
205
+ )
206
+ return connector unless reference
207
+
208
+ connector.style[:association_class_ref] = reference
209
+ connector.source_anchor = connector.waypoints.first.position
210
+ connector
211
+ end
212
+
213
+ def mark_plain_end_seats(connector, placed, context)
214
+ mark_plain_source_seat(connector, placed, context)
215
+ mark_plain_target_seat(connector, placed, context)
216
+ end
217
+
218
+ def association_class_metadata
219
+ @association_class_metadata ||= AssociationClassMetadata.new(xmi_path)
220
+ end
221
+
222
+ def build_connector(placed, by_duid, by_subject, owner_id, options)
168
223
  geom = placed.geometry
169
224
  style = placed.style
170
- edge_source, edge_target = umldi_ends(owner_id, placed)
171
- source = by_duid[style.soid] || sole_placement(by_subject, edge_source)
172
- target = by_duid[style.eoid] || sole_placement(by_subject, edge_target)
225
+ edge_source, edge_target = connector_ends(owner_id, placed)
226
+ source, target = placed_ends(style, by_duid, by_subject, edge_source, edge_target)
173
227
  ext_meta = connector_meta(placed.subject)
174
228
  route = connector_route(placed, source, target, owner_id, ext_meta[:ea_type])
175
- Ea::Model::DiagramConnector.new(
229
+ connector = Ea::Model::DiagramConnector.new(
176
230
  id: IdNormalizer.synthetic_id(owner_id, "conn", placed.subject),
177
231
  diagram_id: owner_id,
178
232
  relationship_ref: placed.subject,
179
- paint_order: [rank],
233
+ paint_order: [options.fetch(:rank)],
180
234
  source_duid: style.soid,
181
235
  target_duid: style.eoid,
182
236
  source_element_ref: source && synthetic_element_id(owner_id, source),
@@ -193,8 +247,111 @@ module Ea
193
247
  line_color: style.color,
194
248
  line_width: style.line_width,
195
249
  hidden: style.hidden,
196
- label_boxes: serialize_label_boxes(geom.label_boxes)
250
+ label_boxes: connector_label_boxes(placed, ext_meta[:ea_type],
251
+ options.fetch(:context) ? nil : owner_id)
197
252
  )
253
+ connector.umldi_label_bounds = if options.fetch(:context)
254
+ {}
255
+ else
256
+ umldi_label_bounds_for(owner_id, placed.subject,
257
+ connector.label_boxes)
258
+ end
259
+ mark_stored_ray(connector, placed, source, target) unless options.fetch(:context)
260
+ mark_association_route(connector, placed, source, target) unless options.fetch(:context)
261
+ connector
262
+ end
263
+
264
+ def umldi_label_bounds_for(owner_id, connector_id, label_boxes)
265
+ return {} unless xmi_path
266
+
267
+ bounds = umldi_edges.label_bounds_for(owner_id, connector_id)
268
+ return {} unless bounds
269
+
270
+ property_roles = umldi_edges.property_role_keys_for(owner_id, connector_id)
271
+ bounds.reject do |key, _|
272
+ extension_key = { source_role: :llt, source_multiplicity: :llb,
273
+ target_role: :lrt, target_multiplicity: :lrb }.fetch(key)
274
+ label_boxes.key?(extension_key) && !property_roles.include?(key)
275
+ end
276
+ end
277
+
278
+ def mark_stored_ray(connector, placed, source, target)
279
+ return if connector.style[:umldi_route]
280
+
281
+ metadata = stored_ray_metadata(connector, placed, source, target)
282
+ metadata.each { |name, value| connector.public_send("#{name}=", value) }
283
+ connector.style[:association_ray_outline] = true unless metadata.empty?
284
+ end
285
+
286
+ def stored_ray_metadata(connector, placed, source, target, stage: :ray_metadata)
287
+ geom = placed.geometry
288
+ link = StoredRoute::Link.new(placed.style.raw, geom.path.to_s.tr("$", ";"))
289
+ bounds = { source: bounds_from_placed(source), target: bounds_from_placed(target) }
290
+ offsets = { source: [geom.sx, geom.sy], target: [geom.ex, geom.ey] }
291
+ StoredRoute.new.public_send(stage, connector, link: link, bounds: bounds, offsets: offsets, edge: geom.edge)
292
+ end
293
+
294
+ def mark_association_route(connector, placed, source, target)
295
+ metadata = stored_ray_metadata(connector, placed, source, target, stage: :association_route)
296
+ metadata.each { |name, value| connector.public_send("#{name}=", value) }
297
+ end
298
+
299
+ def mark_plain_source_seat(connector, placed, context)
300
+ return if context || !placed.geometry.path.to_s.empty? || connector.connector_type != "Aggregation"
301
+ return unless connector.style.to_h.values_at(:mode, :lwidth, :tree) == ["3", "2", nil]
302
+ return unless connector.source_stereotype.to_s.empty?
303
+
304
+ connector.style[:plain_source_seat] = true
305
+ end
306
+
307
+ def mark_plain_target_seat(connector, placed, context)
308
+ return if context || !placed.geometry.path.to_s.empty? || connector.connector_type != "Association"
309
+ return unless connector.style.to_h.values_at(:mode, :lwidth, :tree) == ["3", "2", nil]
310
+ return unless connector.target_stereotype.to_s.empty?
311
+
312
+ connector.style[:plain_target_seat] = true
313
+ end
314
+
315
+ PHANTOM_TYPES = %w[Association Aggregation].freeze
316
+
317
+ # EA exports a model relationship drawn without a diagram link row as
318
+ # an Association or Aggregation link with the default style and an
319
+ # all-zero, EDGE-less, bendless geometry; the QEA source builds it as
320
+ # a phantom connector.
321
+ def phantom_link?(placed, type)
322
+ PHANTOM_TYPES.include?(type) && placed.style.raw == ";Hidden=0;" && default_geometry?(placed.geometry)
323
+ end
324
+
325
+ def default_geometry?(geom)
326
+ geom.edge.nil? && [geom.sx, geom.sy, geom.ex, geom.ey].all?(&:zero?) &&
327
+ geom.path.to_s.empty? && geom.label_boxes.to_h.empty?
328
+ end
329
+
330
+ def connector_label_boxes(placed, type, owner_id)
331
+ return serialize_label_boxes(placed.geometry.label_boxes) unless phantom_link?(placed, type)
332
+
333
+ # An empty extension placement can still have saved UMLDI labels.
334
+ # Synthetic phantom offsets must not suppress those authored bounds.
335
+ return {} if owner_id && xmi_path && umldi_edges.label_bounds_for(owner_id, placed.subject)
336
+
337
+ Qea::DiagramBuilder::DEFAULT_LABEL_BOXES
338
+ end
339
+
340
+ # The saved SOID/EOID can name the model connector's ends in reverse
341
+ # order; the model connector decides which placement is the source.
342
+ def placed_ends(style, by_duid, by_subject, edge_source, edge_target)
343
+ source = by_duid[style.soid] || sole_placement(by_subject, edge_source)
344
+ target = by_duid[style.eoid] || sole_placement(by_subject, edge_target)
345
+ return [source, target] unless source && target &&
346
+ reversed_ends?(source, target, edge_source, edge_target)
347
+
348
+ [target, source]
349
+ end
350
+
351
+ def reversed_ends?(source, target, edge_source, edge_target)
352
+ guid_key(edge_source) != guid_key(edge_target) &&
353
+ guid_key(source.subject) == guid_key(edge_target) &&
354
+ guid_key(target.subject) == guid_key(edge_source)
198
355
  end
199
356
 
200
357
  def connector_meta(subject_id)
@@ -238,11 +395,34 @@ module Ea
238
395
  end
239
396
 
240
397
  def connector_route(placed, source, target, owner_id, type)
398
+ phantom_route(placed, source, target, type) || saved_route(placed, source, target, owner_id, type)
399
+ end
400
+
401
+ def saved_route(placed, source, target, owner_id, type)
241
402
  native = umldi_waypoints(owner_id, placed) if placed.geometry.bend_points.to_a.empty?
242
403
  points = native || compute_waypoints(placed.geometry, source, target,
243
404
  style: placed.style, connector_type: type)
244
405
  native ||= umldi_waypoints(owner_id, placed) if points.empty?
245
- { waypoints: native || points, style: native ? { umldi_route: true } : {} }
406
+ { waypoints: native || points, style: connector_route_style(placed, native) }
407
+ end
408
+
409
+ def phantom_route(placed, source, target, type)
410
+ return unless source && target && phantom_link?(placed, type)
411
+
412
+ points = StoredRoute.new.phantom_waypoints(bounds_from_placed(source), bounds_from_placed(target))
413
+ { waypoints: points.map { |x, y| Ea::Model::Waypoint.new(position: Ea::Model::Point.new(x: x, y: y)) },
414
+ style: { lwidth: "1", phantom_outline: true } }
415
+ end
416
+
417
+ def connector_route_style(placed, native)
418
+ route_style = native ? { umldi_route: true } : {}
419
+ # Label seats use EA's saved routing mode on every route source;
420
+ # umldi_route still marks UMLDI waypoints as authoritative.
421
+ route_style.merge!(Qea::DiagramStyleParser.parse(placed.style.raw).slice(:mode, :lwidth, :tree))
422
+ # Source-known links without DUIDs use classifier endpoint metadata,
423
+ # not a saved label box that might refer to the opposite end.
424
+ route_style[:explicit_end_roles] = true unless placed.style.soid && placed.style.eoid
425
+ route_style
246
426
  end
247
427
 
248
428
  # EA's unique exported edge supplies bendless routes and recovers
@@ -254,6 +434,15 @@ module Ea
254
434
  route&.map { |x, y| Ea::Model::Waypoint.new(position: Ea::Model::Point.new(x: x, y: y)) }
255
435
  end
256
436
 
437
+ def connector_ends(diagram_id, placed)
438
+ umldi_ends(diagram_id, placed) || extension_ends(placed.subject)
439
+ end
440
+
441
+ def extension_ends(subject)
442
+ connector = connector_index[subject]
443
+ [connector&.source&.idref, connector&.target&.idref]
444
+ end
445
+
257
446
  def umldi_ends(diagram_id, placed)
258
447
  return unless xmi_path
259
448
 
@@ -12,8 +12,9 @@ module Ea
12
12
  class ExtensionElements
13
13
  attr_reader :diagram
14
14
 
15
- def initialize(diagram)
15
+ def initialize(diagram, connector_subjects: {})
16
16
  @diagram = diagram
17
+ @connector_subjects = connector_subjects
17
18
  end
18
19
 
19
20
  def placed_elements
@@ -53,9 +54,9 @@ module Ea
53
54
 
54
55
  placement = ExtensionGeometryParser.parse(row.geometry)
55
56
  style = ExtensionStyleParser.parse(row.style)
56
- # Connectors carry SOID/EOID in their style; geometry has
57
- # only bend routing.
58
- return nil unless style.soid && style.eoid
57
+ # Some EA placements omit both DUIDs. The extension connector
58
+ # index still identifies these rows without treating shapes as links.
59
+ return nil unless (style.soid && style.eoid) || @connector_subjects.key?(row.subject)
59
60
 
60
61
  PlacedConnector.new(
61
62
  subject: row.subject,