ea 0.6.137 → 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: 86d6c89e9ac852772ebf6ee8eca6f4a08550af81c7f48908655c94b4bc8ab18c
4
- data.tar.gz: 6ebe1a0e0647909564a7a403730e4041a8eb639ded4479deebbc19db6f3e8e5a
3
+ metadata.gz: 34c3f680cb51a88a683e57db2c059fcee8979e5b6b31cfaae4c3cbc3ac91ac86
4
+ data.tar.gz: a4cbd25958c54c24d6d1801bf4e4cc83f76f42d652f371cf9d491865160d7725
5
5
  SHA512:
6
- metadata.gz: 91e45ba11088734c4ad3998c124945a4a80fc1001e7f3384fd69c02087e7578fcb2d2d1b8c3a6e1b183e26b03c7debd00fc1bdebe216337cb55855573f85f157
7
- data.tar.gz: 6edd840e00dbf74517daad8bd65e2c28d0cec57ad1c02a15016417327d7c3e7dbd1bacfa22c25f6d62d60af632b4d6812110d31523c642a670b386bb86f1a448
6
+ metadata.gz: c87682bc64b73abb6502c0dfd71ca7b9209ab8bcd71b79762032d0c4008f80999e3785257b72f39d73a816247d1dbcc5580a70e64f526a8ee98ee1aab32b645a
7
+ data.tar.gz: ed3c4dd24e1ed8b1bd9c371545d56392cda07b2e0f3223f27d84761b728963c5df7df67fab8da905bdd586150fb94750a5cbe3f2bd1bec9fd11adfd3ec18b9c6
@@ -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,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"]
@@ -50,6 +50,7 @@ module Ea
50
50
  placed_connectors = ext_elements.placed_connectors
51
51
  props = ext_diagram.properties
52
52
  umldi_keywords = keywords_for(id)
53
+ context = context_diagram?(ext_diagram)
53
54
 
54
55
  Ea::Model::Diagram.new(
55
56
  id: id,
@@ -65,8 +66,7 @@ module Ea
65
66
  show_parents: !style_flag?(ext_diagram, "HideParents"),
66
67
  hand_draw: style_flag?(ext_diagram, "HandDraw"),
67
68
  elements: build_elements(placed_elements, id, umldi_keywords),
68
- connectors: build_connectors(placed_connectors,
69
- placed_elements, id),
69
+ connectors: build_connectors(placed_connectors, placed_elements, id, context),
70
70
  annotations: []
71
71
  )
72
72
  end
@@ -102,6 +102,13 @@ module Ea
102
102
  value.to_i.nonzero? ? true : false
103
103
  end
104
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
+
105
112
  def page_bounds(ext_diagram)
106
113
  style = style_for(ext_diagram).to_s
107
114
  width = style[/(?:\A|;)DocSize\.cx=(\d+)/, 1]
@@ -176,7 +183,7 @@ module Ea
176
183
  height: bottom - top)
177
184
  end
178
185
 
179
- def build_connectors(placed_connectors, placed_elements, owner_id)
186
+ def build_connectors(placed_connectors, placed_elements, owner_id, context)
180
187
  # Index elements by DUID so we can resolve SOID/EOID.
181
188
  by_duid = placed_elements.each_with_object({}) do |pe, acc|
182
189
  acc[pe.style.duid] = pe if pe.style.duid
@@ -184,40 +191,46 @@ module Ea
184
191
  by_subject = placed_elements.group_by { |pe| guid_key(pe.subject) }
185
192
 
186
193
  placed_connectors.each_with_index.map do |placed, index|
187
- connector = build_connector(placed, by_duid, by_subject, owner_id, index)
188
- mark_association_class(connector, placed, placed_elements)
194
+ connector = build_connector(placed, by_duid, by_subject, owner_id, rank: index, context: context)
195
+ mark_association_class(connector, placed, placed_elements, context)
189
196
  end
190
197
  end
191
198
 
192
- def mark_association_class(connector, placed, placed_elements)
199
+ def mark_association_class(connector, placed, placed_elements, context)
200
+ mark_plain_end_seats(connector, placed, context)
193
201
  reference = association_class_metadata.for(
194
202
  connector_index[placed.subject],
195
203
  placed_subjects: placed_elements.map(&:subject),
196
204
  waypoints: connector.waypoints
197
205
  )
198
- if reference
199
- connector.style[:association_class_ref] = reference
200
- connector.source_anchor = connector.waypoints.first.position
201
- end
206
+ return connector unless reference
207
+
208
+ connector.style[:association_class_ref] = reference
209
+ connector.source_anchor = connector.waypoints.first.position
202
210
  connector
203
211
  end
204
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
+
205
218
  def association_class_metadata
206
219
  @association_class_metadata ||= AssociationClassMetadata.new(xmi_path)
207
220
  end
208
221
 
209
- def build_connector(placed, by_duid, by_subject, owner_id, rank)
222
+ def build_connector(placed, by_duid, by_subject, owner_id, options)
210
223
  geom = placed.geometry
211
224
  style = placed.style
212
225
  edge_source, edge_target = connector_ends(owner_id, placed)
213
226
  source, target = placed_ends(style, by_duid, by_subject, edge_source, edge_target)
214
227
  ext_meta = connector_meta(placed.subject)
215
228
  route = connector_route(placed, source, target, owner_id, ext_meta[:ea_type])
216
- Ea::Model::DiagramConnector.new(
229
+ connector = Ea::Model::DiagramConnector.new(
217
230
  id: IdNormalizer.synthetic_id(owner_id, "conn", placed.subject),
218
231
  diagram_id: owner_id,
219
232
  relationship_ref: placed.subject,
220
- paint_order: [rank],
233
+ paint_order: [options.fetch(:rank)],
221
234
  source_duid: style.soid,
222
235
  target_duid: style.eoid,
223
236
  source_element_ref: source && synthetic_element_id(owner_id, source),
@@ -234,8 +247,96 @@ module Ea
234
247
  line_color: style.color,
235
248
  line_width: style.line_width,
236
249
  hidden: style.hidden,
237
- 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)
238
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
+ return if metadata.empty? || connector.connector_type == "Generalization"
284
+
285
+ connector.style[:association_ray_outline] = true
286
+ end
287
+
288
+ def stored_ray_metadata(connector, placed, source, target, stage: :ray_metadata)
289
+ geom = placed.geometry
290
+ link = StoredRoute::Link.new(placed.style.raw, geom.path.to_s.tr("$", ";"))
291
+ bounds = { source: bounds_from_placed(source), target: bounds_from_placed(target) }
292
+ offsets = { source: [geom.sx, geom.sy], target: [geom.ex, geom.ey] }
293
+ StoredRoute.new.public_send(stage, connector, link: link, bounds: bounds, offsets: offsets, edge: geom.edge)
294
+ end
295
+
296
+ def mark_association_route(connector, placed, source, target)
297
+ metadata = stored_ray_metadata(connector, placed, source, target, stage: :association_route)
298
+ metadata.each { |name, value| connector.public_send("#{name}=", value) }
299
+ end
300
+
301
+ def mark_plain_source_seat(connector, placed, context)
302
+ return if context || !placed.geometry.path.to_s.empty? || connector.connector_type != "Aggregation"
303
+ return unless connector.style.to_h.values_at(:mode, :lwidth, :tree) == ["3", "2", nil]
304
+ return unless connector.source_stereotype.to_s.empty?
305
+
306
+ connector.style[:plain_source_seat] = true
307
+ end
308
+
309
+ def mark_plain_target_seat(connector, placed, context)
310
+ return if context || !placed.geometry.path.to_s.empty? || connector.connector_type != "Association"
311
+ return unless connector.style.to_h.values_at(:mode, :lwidth, :tree) == ["3", "2", nil]
312
+ return unless connector.target_stereotype.to_s.empty?
313
+
314
+ connector.style[:plain_target_seat] = true
315
+ end
316
+
317
+ PHANTOM_TYPES = %w[Association Aggregation].freeze
318
+
319
+ # EA exports a model relationship drawn without a diagram link row as
320
+ # an Association or Aggregation link with the default style and an
321
+ # all-zero, EDGE-less, bendless geometry; the QEA source builds it as
322
+ # a phantom connector.
323
+ def phantom_link?(placed, type)
324
+ PHANTOM_TYPES.include?(type) && placed.style.raw == ";Hidden=0;" && default_geometry?(placed.geometry)
325
+ end
326
+
327
+ def default_geometry?(geom)
328
+ geom.edge.nil? && [geom.sx, geom.sy, geom.ex, geom.ey].all?(&:zero?) &&
329
+ geom.path.to_s.empty? && geom.label_boxes.to_h.empty?
330
+ end
331
+
332
+ def connector_label_boxes(placed, type, owner_id)
333
+ return serialize_label_boxes(placed.geometry.label_boxes) unless phantom_link?(placed, type)
334
+
335
+ # An empty extension placement can still have saved UMLDI labels.
336
+ # Synthetic phantom offsets must not suppress those authored bounds.
337
+ return {} if owner_id && xmi_path && umldi_edges.label_bounds_for(owner_id, placed.subject)
338
+
339
+ Qea::DiagramBuilder::DEFAULT_LABEL_BOXES
239
340
  end
240
341
 
241
342
  # The saved SOID/EOID can name the model connector's ends in reverse
@@ -296,6 +397,10 @@ module Ea
296
397
  end
297
398
 
298
399
  def connector_route(placed, source, target, owner_id, type)
400
+ phantom_route(placed, source, target, type) || saved_route(placed, source, target, owner_id, type)
401
+ end
402
+
403
+ def saved_route(placed, source, target, owner_id, type)
299
404
  native = umldi_waypoints(owner_id, placed) if placed.geometry.bend_points.to_a.empty?
300
405
  points = native || compute_waypoints(placed.geometry, source, target,
301
406
  style: placed.style, connector_type: type)
@@ -303,6 +408,14 @@ module Ea
303
408
  { waypoints: native || points, style: connector_route_style(placed, native) }
304
409
  end
305
410
 
411
+ def phantom_route(placed, source, target, type)
412
+ return unless source && target && phantom_link?(placed, type)
413
+
414
+ points = StoredRoute.new.phantom_waypoints(bounds_from_placed(source), bounds_from_placed(target))
415
+ { waypoints: points.map { |x, y| Ea::Model::Waypoint.new(position: Ea::Model::Point.new(x: x, y: y)) },
416
+ style: { lwidth: "1", phantom_outline: true } }
417
+ end
418
+
306
419
  def connector_route_style(placed, native)
307
420
  route_style = native ? { umldi_route: true } : {}
308
421
  # Label seats use EA's saved routing mode on every route source;
@@ -26,8 +26,210 @@ module Ea
26
26
  finish(points, link, connector, source, target, bends, ports, offsets, edge)
27
27
  end
28
28
 
29
+ # Saved rays retain origins and computed boundary ports, matching QEA.
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
+
35
+ return {} unless direct_association_ray?(model, bounds, offsets)
36
+
37
+ source, target = bounds.values_at(:source, :target)
38
+ geom = ray_geometry(offsets, edge)
39
+ return {} unless saved_association_ray?(model, link, source, target, geom)
40
+
41
+ ray_points(source, target, offsets, geom)
42
+ end
43
+
44
+ def phantom_waypoints(source, target)
45
+ [bounds_edge_point(source, target), bounds_edge_point(target, source)]
46
+ end
47
+
48
+ # Apply QEA's saved Association stages after the initial route, including
49
+ # UMLDI fallback. A single stored corner and an empty-path stored ray
50
+ # carry mathematical origins that the generic facing-edge route loses.
51
+ def association_route(model, link:, bounds:, offsets:, edge:)
52
+ overlap = umldi_association_metadata(model, link, bounds, offsets, edge)
53
+ return overlap unless overlap.empty?
54
+
55
+ partial = partial_port_metadata(model, link, bounds, offsets, edge)
56
+ return partial unless partial.empty?
57
+
58
+ return {} unless stored_association_candidate?(model, bounds)
59
+
60
+ candidate = association_model(model, bounds, offsets)
61
+ initial = candidate.waypoints
62
+ association_stored_ray(candidate, link, bounds, ray_geometry(offsets, edge), false)
63
+ return {} if candidate.waypoints.equal?(initial)
64
+
65
+ association_metadata(candidate)
66
+ end
67
+
29
68
  private
30
69
 
70
+ def umldi_association_metadata(model, link, bounds, offsets, edge)
71
+ overlap = umldi_overlap_metadata(model, link, bounds, offsets, edge)
72
+ return overlap unless overlap.empty?
73
+
74
+ umldi_stored_ray_metadata(model, link, bounds, offsets, edge)
75
+ end
76
+
77
+ # UMLDI owns the route and ports. Classify a thin saved ray only when
78
+ # its origins pass the shared unstroked-box checks; XMI has no QEA
79
+ # database from which to infer a wider element outline.
80
+ def umldi_stored_ray_metadata(model, link, bounds, offsets, edge)
81
+ return {} unless umldi_thin_ray_candidate?(model, bounds, offsets)
82
+
83
+ candidate = association_model(model, bounds, offsets)
84
+ return {} unless association_ray_candidate?(candidate, link)
85
+ return {} unless association_ray_origins(*bounds.values_at(:source, :target),
86
+ ray_geometry(offsets, edge), "0")
87
+
88
+ { style: model.style.to_h.merge(stored_association_ray: true, association_ray_outline: true) }
89
+ end
90
+
91
+ def umldi_thin_ray_candidate?(model, bounds, offsets)
92
+ model.style[:umldi_route] && model.style[:lwidth] == "0" &&
93
+ bounds.values.all? && offsets.values.flatten.none?(&:nil?)
94
+ end
95
+
96
+ # UMLDI owns the drawn route; retain QEA's overlap origins only for
97
+ # interpreting the stored relative end labels against that route.
98
+ def umldi_overlap_metadata(model, link, bounds, offsets, edge)
99
+ return {} unless umldi_overlap_candidate?(model, bounds, offsets)
100
+
101
+ candidate = association_model(model, bounds, offsets)
102
+ overlap_ray_origins(candidate, link, bounds, ray_geometry(offsets, edge), false)
103
+ return {} unless candidate.style[:overlap_ray]
104
+
105
+ { source_anchor: candidate.source_anchor, target_anchor: candidate.target_anchor,
106
+ has_geometry_offsets: true, style: candidate.style.to_h }
107
+ end
108
+
109
+ def umldi_overlap_candidate?(model, bounds, offsets)
110
+ model.style.to_h.values_at(:umldi_route, :mode, :lwidth) == [true, "2", "0"] &&
111
+ model.waypoints.size == 2 && bounds.values.all? && offsets.values.flatten.none?(&:nil?)
112
+ end
113
+
114
+ # A zero-offset end has no explicit port, but QEA still carries both
115
+ # mathematical origins for the emitter's final boundary redocking.
116
+ def partial_port_metadata(model, link, bounds, offsets, edge)
117
+ return {} unless partial_port_candidate?(model, link, bounds, offsets)
118
+
119
+ source, target = bounds.values_at(:source, :target)
120
+ return {} unless saved_association_ray?(model, link, source, target, ray_geometry(offsets, edge))
121
+
122
+ association_ends(bounds, offsets).merge(has_geometry_offsets: true)
123
+ end
124
+
125
+ def partial_port_candidate?(model, link, bounds, offsets)
126
+ model.connector_type == "Aggregation" && !model.style[:umldi_route] && !link.path.to_s.empty? &&
127
+ direct_association_ray?(model, bounds, offsets) &&
128
+ offsets.values.count { |offset| offset.all?(&:zero?) } == 1
129
+ end
130
+
131
+ def stored_association_candidate?(model, bounds)
132
+ model.connector_type == "Association" && bounds.values.all? &&
133
+ association_ray_style?(model.style.to_h) && model.style[:lwidth] == "2"
134
+ end
135
+
136
+ def association_metadata(model)
137
+ fields = %i[waypoints source_anchor target_anchor source_port target_port has_geometry_offsets]
138
+ fields.to_h { |name| [name, model.public_send(name)] }.merge(
139
+ style: model.style.to_h.reject { |key, _| key == :umldi_route }
140
+ )
141
+ end
142
+
143
+ def association_model(model, bounds, offsets)
144
+ Ea::Model::DiagramConnector.new(
145
+ { connector_type: model.connector_type, style: model.style.to_h.dup, waypoints: model.waypoints,
146
+ has_geometry_offsets: !offsets[:source][0].nil? }.merge(association_ends(bounds, offsets))
147
+ )
148
+ end
149
+
150
+ def association_ends(bounds, offsets)
151
+ %i[source target].each_with_object({}) do |kind, result|
152
+ box = bounds.fetch(kind)
153
+ offset = offsets.fetch(kind)
154
+ port = port_for(box, offset)
155
+ result["#{kind}_port".to_sym] = point_for(port) if port
156
+ result["#{kind}_anchor".to_sym] = association_anchor(box, offset)
157
+ end
158
+ end
159
+
160
+ def association_anchor(box, offset)
161
+ Ea::Model::Point.new(x: box.x + box.width / 2 + offset[0].to_i,
162
+ y: box.y + box.height / 2 + offset[1].to_i)
163
+ end
164
+
165
+ def stored_ray_boxes(_link, bounds)
166
+ bounds.values_at(:source, :target)
167
+ end
168
+
169
+ def direct_association_ray?(model, bounds, offsets)
170
+ %w[Association Aggregation].include?(model.connector_type) && model.waypoints.size == 2 &&
171
+ model.style.to_h.values_at(:mode, :lwidth, :tree) == ["1", "2", nil] &&
172
+ bounds.values.all? && offsets.values.flatten.none?(&:nil?)
173
+ end
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
+
208
+ def ray_geometry(offsets, edge)
209
+ { sx: offsets[:source][0], sy: offsets[:source][1],
210
+ ex: offsets[:target][0], ey: offsets[:target][1], edge: edge }
211
+ end
212
+
213
+ def saved_association_ray?(model, link, source, target, geom)
214
+ (!geom[:sy].zero? || !geom[:ey].zero?) &&
215
+ stored_ray_gap?(source, target, "1", geom[:edge]) &&
216
+ stored_association_path?(model, link, source, target, geom)
217
+ end
218
+
219
+ def ray_points(source, target, offsets, geom)
220
+ ports = [port_for(source, offsets[:source]), port_for(target, offsets[:target])]
221
+ return {} unless ports.all?
222
+
223
+ { source_port: point_for(ports.first), target_port: point_for(ports.last),
224
+ source_anchor: stored_ray_origin(source, geom[:sx], geom[:sy]),
225
+ target_anchor: stored_ray_origin(target, geom[:ex], geom[:ey]),
226
+ has_geometry_offsets: true }
227
+ end
228
+
229
+ def point_for(pair)
230
+ Ea::Model::Point.new(x: pair[0], y: pair[1])
231
+ end
232
+
31
233
  # Placements here are already Ea::Model::Bounds.
32
234
  def bounds_from_rect(bounds)
33
235
  bounds
@@ -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
 
@@ -22,9 +22,11 @@ module Ea
22
22
  SEAT_FONT_SIZE = 7
23
23
  SEAT_X_INSET = 3
24
24
  SEAT_STEREOTYPE_GAP = 12
25
- # EA keeps an upward property seat's left edge at diagram x >= 3;
26
- # the stereotype line moves with the role. Measured only for the
27
- # default Cambria 7pt boxed cluster.
25
+ # EA keeps an end-label role's left edge at diagram x >= 3; the
26
+ # stereotype line moves with the role. Measured on the Plateau
27
+ # v5.1 export: of 776 saved end labels, the only three whose
28
+ # UMLDI bounds start left of 3 (47E4600F, CB34B578, 159E9DA0) are
29
+ # drawn at x = 3, and no label is drawn left of it.
28
30
  MIN_ROLE_X = 3
29
31
 
30
32
  attr_reader :canvas, :model_index, :document, :theme,
@@ -61,12 +63,13 @@ module Ea
61
63
  # fallback
62
64
  def texts(text_box:, mult_box:, anchor:, connector:, end_kind:,
63
65
  tree: false, heading: nil, stored_labels: false, bend: nil, source_bounds: nil,
64
- target_face: nil)
66
+ target_face: nil, umldi_bounds: {})
65
67
  from_property, role, mult = role_and_multiplicity(connector, end_kind)
66
68
  if role_empty?(role) && mult_empty?(mult)
67
69
  other_from_prop, other_role, other_mult = role_and_multiplicity(connector,
68
70
  opposite(end_kind))
69
- if stored_labels || connector.style.to_h[:explicit_end_roles]
71
+ if stored_labels || connector.style.to_h[:explicit_end_roles] ||
72
+ connector.style.to_h[:phantom_outline] == true
70
73
  return [] if role_empty?(other_role) && mult_empty?(other_mult)
71
74
 
72
75
  return own_stereotype_only(text_box, anchor, connector,
@@ -104,7 +107,7 @@ module Ea
104
107
  plain_vertical_role(connector, direction, anchor, text_box, role) ||
105
108
  source_property_seat(connector, direction, anchor, text_box, role) ||
106
109
  plain_upward_seat(connector, direction, anchor, text_box, role) ||
107
- role_seat(direction, anchor, text_box, role) || text_pos
110
+ role_seat(connector, direction, anchor, text_box, role) || text_pos
108
111
  mult_pos = directional_multiplicity(direction, anchor, mult_box, mult) ||
109
112
  horizontal_tree_multiplicity(connector, direction, anchor, mult_box, mult) ||
110
113
  mult_seat(direction, anchor, mult_box) || mult_pos
@@ -149,12 +152,53 @@ module Ea
149
152
  text_pos, mult_pos = stair_positions if stair_positions
150
153
  end
151
154
 
155
+ role_bounds = umldi_bounds[end_kind == :source ? :source_role : :target_role]
156
+ mult_bounds = umldi_bounds[end_kind == :source ? :source_multiplicity : :target_multiplicity]
157
+ text_pos = umldi_label_position(role_bounds) if role_bounds
158
+ centering = role_bounds ? bounds_role_centering(connector, end_kind, role, stereo, text_box) : 0
159
+ text_pos = [text_pos[0] + centering, text_pos[1]] if centering.positive?
160
+ mult_pos = umldi_label_position(mult_bounds) if mult_bounds
161
+ if stereo && role_bounds
162
+ stereo_pos = umldi_stereotype_position(role_bounds, role, stereo, text_box)
163
+ stereo_pos = [role_bounds[:x], stereo_pos[1]] if centering.positive?
164
+ end
165
+
166
+ if text_pos && !role_empty?(role) && min_role_x_cluster?(connector, end_kind, heading, text_box)
167
+ text_pos, stereo_pos = clamp_role_x(text_pos, stereo_pos)
168
+ end
169
+
152
170
  build_texts(role:, mult:, standalone:,
153
- text_pos:, mult_pos:, stereo:, text_box:)
171
+ text_pos:, mult_pos:, stereo:, text_box:, stereo_pos:)
154
172
  end
155
173
 
156
174
  private
157
175
 
176
+ # Measured only for default Cambria 7pt Property roles on a vertical
177
+ # leg rising from the end; other fonts, boxes and legs keep their
178
+ # unclamped seats.
179
+ def min_role_x_cluster?(connector, end_kind, heading, text_box)
180
+ rising_vertical?(heading) && plain_seat_font? &&
181
+ cambria_box?(text_box) && property_label?(connector, end_kind)
182
+ end
183
+
184
+ def rising_vertical?(heading)
185
+ heading && heading[0].zero? && heading[1].negative?
186
+ end
187
+
188
+ def plain_seat_font?
189
+ seat_font? && font_weight == 400 && font_style == "normal"
190
+ end
191
+
192
+ # Shift the role and its stereotype line together when the role
193
+ # would start left of EA's minimum label x.
194
+ def clamp_role_x(text_pos, stereo_pos)
195
+ shift = MIN_ROLE_X - text_pos[0]
196
+ return [text_pos, stereo_pos] unless shift.positive?
197
+
198
+ [[text_pos[0] + shift, text_pos[1]],
199
+ stereo_pos && [stereo_pos[0] + shift, stereo_pos[1]]]
200
+ end
201
+
158
202
  # ALN2 single-line target roles retain their horizontal placement
159
203
  # and use the saved corner's baseline on a straight stored ray.
160
204
  def horizontal_aligned_target_baseline?(connector, heading, box, role)
@@ -190,8 +234,11 @@ module Ea
190
234
  [x - width - SEAT_X_INSET, y - 7] if width
191
235
  end
192
236
 
237
+ # UMLDI routes carry the inferred saved-ray flag; their stored LRT
238
+ # box keeps the plain target seat.
193
239
  def moved_plain_target_ray?(connector, box)
194
- saved_target_ray?(connector) && box.values_at("alignment", "cy") == [1, 16] &&
240
+ saved_target_ray?(connector) && connector.style.to_h[:umldi_route] != true &&
241
+ box.values_at("alignment", "cy") == [1, 16] &&
195
242
  !stored_offsets_zero?(box)
196
243
  end
197
244
 
@@ -318,15 +365,33 @@ module Ea
318
365
  nil
319
366
  end
320
367
 
368
+ # Saved bounds seat a source Property role at the stereotype's left
369
+ # edge. A centred stored box (ALN=1), or a phantom's synthesized box
370
+ # without alignment, centres the shorter role over it; a
371
+ # left-aligned box keeps both lines on that edge.
372
+ def bounds_role_centering(connector, end_kind, role, stereo, text_box)
373
+ return 0 unless end_kind == :source && stereo && seat_font?
374
+ return 0 unless centred_bounds_box?(connector, text_box)
375
+ return 0 unless property_label?(connector, :source)
376
+
377
+ -[stereotype_offset(role, stereo, { "alignment" => 1 }), 0].min
378
+ end
379
+
380
+ # Phantom outlines have no saved diagram link, so their boxes are
381
+ # synthesized and EA draws its centred default label.
382
+ def centred_bounds_box?(connector, text_box)
383
+ connector.style.to_h[:phantom_outline] == true || text_box.to_h["alignment"] == 1
384
+ end
385
+
321
386
  # Role text on a rightward leg is left-aligned at the corner,
322
387
  # 7px above it.
323
- def role_seat(direction, anchor, box, role)
388
+ def role_seat(connector, direction, anchor, box, role)
324
389
  return nil unless direction == :right
325
390
  return nil if box.nil? || role_empty?(role)
326
391
  return nil unless default_presentation?(box)
327
392
 
328
393
  x, y = seat_corner(anchor, box)
329
- [x + SEAT_X_INSET, y - 7]
394
+ [x + SEAT_X_INSET + source_property_centering(connector, direction, box, role), y - 7]
330
395
  end
331
396
 
332
397
  # Property-role clusters use a direction-specific horizontal seat;
@@ -348,12 +413,26 @@ module Ea
348
413
  # Center a short role above the wider stereotype without moving
349
414
  # the directional cluster's left edge. Other boxes keep their seat.
350
415
  def source_property_centering(connector, direction, box, role)
351
- return 0 unless %i[left down].include?(direction)
416
+ return 0 unless centered_property_box?(connector, direction, box)
417
+
418
+ property_role_centering(connector, :source, box, role)
419
+ end
420
+
421
+ def property_role_centering(connector, end_kind, box, role)
352
422
  return 0 unless seat_font? && font_weight == 400 && font_style == "normal"
353
- return 0 unless box.values_at("cy", "alignment") == [35, 1]
423
+ return 0 unless property_label?(connector, end_kind)
424
+
425
+ stereo = "«#{connector_source_dest_stereotype(connector, end_kind)}»"
426
+ -[stereotype_offset(role, stereo, box.merge("alignment" => 1)), 0].min
427
+ end
354
428
 
355
- stereo = "«#{connector_source_dest_stereotype(connector, :source)}»"
356
- -[stereotype_offset(role, stereo, box), 0].min
429
+ # Measured box heights: left/down Property clusters store CY 35,
430
+ # right-seat clusters CY 31. Phantom outlines leaving down carry a
431
+ # synthesized zero box that centers the same way.
432
+ def centered_property_box?(connector, direction, box)
433
+ cy = { left: 35, down: 35, right: 31 }[direction]
434
+ (cy && box.values_at("cy", "alignment") == [cy, 1]) ||
435
+ (direction == :down && phantom_property_box?(connector, direction, box))
357
436
  end
358
437
 
359
438
  def source_property_seat?(connector, direction, box)
@@ -367,7 +446,7 @@ module Ea
367
446
  # Their default Property cluster uses the directional seat with a
368
447
  # centered stereotype; persisted or moved boxes keep their own seat.
369
448
  def phantom_property_box?(connector, direction, box)
370
- %i[up left].include?(direction) && seat_font? && cambria_box?(box) &&
449
+ %i[up left down].include?(direction) && seat_font? && cambria_box?(box) &&
371
450
  box["alignment"].nil? && stored_offsets_zero?(box) &&
372
451
  connector.style.to_h[:phantom_outline] == true &&
373
452
  property_label?(connector, :source)
@@ -522,7 +601,7 @@ module Ea
522
601
 
523
602
  x, y = seat_corner(anchor, box)
524
603
  inset, = property_role_offsets(:down)
525
- [x - inset - width, y - 7]
604
+ [x - inset - width + property_role_centering(connector, :target, box, role), y - 7]
526
605
  end
527
606
 
528
607
  def upward_target_property?(connector, heading)
@@ -665,7 +744,8 @@ module Ea
665
744
 
666
745
  def downward_source_route?(style, box)
667
746
  [orthogonal_stub_route?(style), lateral_horizontal_route?(style), mode_two_route?(style),
668
- downward_property_route?(style), persisted_width_zero_down_box?(box, style)].any?
747
+ downward_property_route?(style), persisted_width_zero_down_box?(box, style),
748
+ centered_width_zero_down_box?(box, style)].any?
669
749
  end
670
750
 
671
751
  def persisted_width_zero_down_box?(box, style)
@@ -673,6 +753,11 @@ module Ea
673
753
  style.values_at(:mode, :lwidth, :tree) == ["3", "0", nil]
674
754
  end
675
755
 
756
+ def centered_width_zero_down_box?(box, style)
757
+ box["alignment"] == 1 && stored_offsets_zero?(box) &&
758
+ style.values_at(:mode, :lwidth, :tree) == ["3", "0", nil]
759
+ end
760
+
676
761
  # Mode 3, width 2 on the horizontal lateral tree.
677
762
  def lateral_horizontal_route?(style)
678
763
  style.values_at(:mode, :lwidth, :tree) == %w[3 2 LH]
@@ -820,13 +905,14 @@ module Ea
820
905
  end
821
906
 
822
907
  def build_texts(role:, mult:, standalone:, text_pos:, mult_pos:,
823
- stereo:, text_box:)
908
+ stereo:, text_box:, stereo_pos: nil)
824
909
  if role && !role.empty? && text_pos
825
910
  x, y = text_pos
826
911
  texts = [text_at(x, y, role)]
827
912
  if stereo
828
- stereo_x = x + stereotype_offset(role, stereo, text_box)
829
- texts << text_at(stereo_x, y + property_y_offset(text_box), stereo)
913
+ stereo_x, stereo_y = stereo_pos || [x + stereotype_offset(role, stereo, text_box),
914
+ y + property_y_offset(text_box)]
915
+ texts << text_at(stereo_x, stereo_y, stereo)
830
916
  end
831
917
  if mult && !mult.empty? && mult_pos
832
918
  texts << text_at(*mult_pos, mult)
@@ -839,6 +925,19 @@ module Ea
839
925
  end
840
926
  end
841
927
 
928
+ def umldi_label_position(bounds)
929
+ [bounds[:x], bounds[:y] + font_pixel_size.ceil]
930
+ end
931
+
932
+ def umldi_stereotype_position(bounds, role, stereo, text_box)
933
+ [bounds[:x] + stereotype_offset(role, stereo, text_box),
934
+ bounds[:y] + font_pixel_size.ceil + (font_pixel_size.ceil + 2)]
935
+ end
936
+
937
+ def font_pixel_size
938
+ font_unit == "pt" ? font_size * 4.0 / 3 : font_size
939
+ end
940
+
842
941
  def stereotype_offset(role, stereo, text_box)
843
942
  return 0 unless text_box && text_box["alignment"] == 1
844
943
 
@@ -83,7 +83,8 @@ module Ea
83
83
 
84
84
  stored_src = end_boxes(boxes, :llt, :llb)
85
85
  stored_tgt = end_boxes(boxes, :lrt, :lrb)
86
- stored_labels = !(stored_src.nil? && stored_tgt.nil?)
86
+ umldi_bounds = connector.umldi_label_bounds || {}
87
+ stored_labels = !(stored_src.nil? && stored_tgt.nil?) || !umldi_bounds.empty?
87
88
  src_tb, src_mb = stored_src || unstored_label_boxes(points, :source, stored_labels)
88
89
  tgt_tb, tgt_mb = stored_tgt || unstored_label_boxes(points, :target, stored_labels)
89
90
 
@@ -93,6 +94,7 @@ module Ea
93
94
  end_kind: :source, tree: tree && tree[:source],
94
95
  heading: (stored_src || stored_labels) && source_heading(connector, points),
95
96
  stored_labels: stored_labels,
97
+ umldi_bounds: available_umldi_bounds(umldi_bounds, boxes, :source),
96
98
  source_bounds: source_dock_bounds(connector)).each { |t| texts << t }
97
99
  end_renderer.texts(text_box: tgt_tb,
98
100
  mult_box: tgt_mb,
@@ -101,10 +103,21 @@ module Ea
101
103
  bend: Label::TargetBendSeat.reference(points),
102
104
  heading: (stored_tgt || stored_labels) && target_heading(connector, points),
103
105
  target_face: target_dock_heading(connector, target_pt, target_heading(connector, points)),
104
- stored_labels: stored_labels).each { |t| texts << t }
106
+ stored_labels: stored_labels,
107
+ umldi_bounds: available_umldi_bounds(umldi_bounds, boxes, :target)).each { |t| texts << t }
105
108
  texts
106
109
  end
107
110
 
111
+ # Role bounds the XMI builder keeps belong to this connector's own
112
+ # memberEnd and win over a stored role box; a stored multiplicity box
113
+ # wins over multiplicity bounds.
114
+ def available_umldi_bounds(bounds, label_boxes, end_kind)
115
+ mult_key = end_kind == :source ? :llb : :lrb
116
+ result = bounds.dup
117
+ result.delete(end_kind == :source ? :source_multiplicity : :target_multiplicity) if label_boxes.key?(mult_key)
118
+ result
119
+ end
120
+
108
121
  # A connector that stores label boxes for only one end seats the
109
122
  # other end's labels at its anchor, like a stored zero offset.
110
123
  def unstored_label_boxes(points, end_kind, stored_labels)
@@ -52,11 +52,16 @@ module Ea
52
52
  # feature compartment — even one whose rows are all hidden
53
53
  # by visibility flags (10E3E08A: CodeList with 3 attributes
54
54
  # toggled off still renders the divider over an empty body;
55
- # only feature-less elements omit it).
55
+ # only feature-less elements omit it). Association-end
56
+ # properties are not EA attributes (QEA keeps them as connector
57
+ # roles; the XMI reads them as ownedAttribute), so a class whose
58
+ # only properties are association ends has no feature compartment
59
+ # and no divider (A59065AF urf::Administration).
56
60
  def hidden_features_present?
57
61
  return false unless classifier.respond_to?(:properties)
58
62
 
59
- classifier.properties.any? || classifier.operations.any?
63
+ classifier.properties.any? { |property| !property.association_id } ||
64
+ classifier.operations.any?
60
65
  end
61
66
  end
62
67
  end
@@ -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.137"
7
+ VERSION = "0.6.139"
8
8
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ea
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.137
4
+ version: 0.6.139
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-20 00:00:00.000000000 Z
11
+ date: 2026-10-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: lutaml-model
@@ -857,6 +857,7 @@ files:
857
857
  - lib/ea/sources/xmi/diagram_builder/font_settings.rb
858
858
  - lib/ea/sources/xmi/diagram_builder/stereotype_profiles.rb
859
859
  - lib/ea/sources/xmi/diagram_builder/umldi_edges.rb
860
+ - lib/ea/sources/xmi/diagram_builder/umldi_edges/property_roles.rb
860
861
  - lib/ea/sources/xmi/extension_elements.rb
861
862
  - lib/ea/sources/xmi/extension_geometry_parser.rb
862
863
  - lib/ea/sources/xmi/extension_style_parser.rb
@@ -976,6 +977,7 @@ files:
976
977
  - lib/ea/svg/ea_emitter/markers.rb
977
978
  - lib/ea/svg/ea_emitter/orthogonal_ray.rb
978
979
  - lib/ea/svg/ea_emitter/render_context.rb
980
+ - lib/ea/svg/ea_emitter/segment_geometry.rb
979
981
  - lib/ea/svg/ea_emitter/style.rb
980
982
  - lib/ea/svg/ea_emitter/text_renderer.rb
981
983
  - lib/ea/svg/ea_emitter/visibility_symbol.rb