ea 0.6.138 → 0.6.140

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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/lib/ea/fonts/metrics.rb +22 -2
  3. data/lib/ea/model/note.rb +2 -0
  4. data/lib/ea/sources/qea/diagram_builder/routing.rb +140 -2
  5. data/lib/ea/sources/qea/diagram_builder.rb +10 -0
  6. data/lib/ea/sources/qea/note_builder.rb +7 -1
  7. data/lib/ea/sources/xmi/diagram_builder/element_borders.rb +27 -0
  8. data/lib/ea/sources/xmi/diagram_builder.rb +40 -11
  9. data/lib/ea/sources/xmi/note_builder.rb +2 -1
  10. data/lib/ea/sources/xmi/stored_route/reversed_routes.rb +94 -0
  11. data/lib/ea/sources/xmi/stored_route.rb +121 -8
  12. data/lib/ea/svg/ea_emitter/bottom_margin.rb +100 -0
  13. data/lib/ea/svg/ea_emitter/bounds_calculator.rb +3 -1
  14. data/lib/ea/svg/ea_emitter/canvas.rb +37 -9
  15. data/lib/ea/svg/ea_emitter/canvas_anchor.rb +124 -0
  16. data/lib/ea/svg/ea_emitter/compartment/note_body.rb +5 -1
  17. data/lib/ea/svg/ea_emitter/compartment/package_from_parent.rb +23 -4
  18. data/lib/ea/svg/ea_emitter/compartment/shape.rb +17 -5
  19. data/lib/ea/svg/ea_emitter/connector_bridges.rb +113 -41
  20. data/lib/ea/svg/ea_emitter/connectors.rb +67 -17
  21. data/lib/ea/svg/ea_emitter/content_extent.rb +95 -0
  22. data/lib/ea/svg/ea_emitter/document.rb +56 -27
  23. data/lib/ea/svg/ea_emitter/element/legend_renderer.rb +14 -6
  24. data/lib/ea/svg/ea_emitter/element/note_shape_renderer.rb +64 -32
  25. data/lib/ea/svg/ea_emitter/elements.rb +21 -4
  26. data/lib/ea/svg/ea_emitter/label/end_label.rb +64 -6
  27. data/lib/ea/svg/ea_emitter/labels.rb +6 -0
  28. data/lib/ea/svg/ea_emitter/layer_sequencer.rb +40 -26
  29. data/lib/ea/svg/ea_emitter/markers.rb +22 -10
  30. data/lib/ea/svg/ea_emitter/paint_order.rb +29 -0
  31. data/lib/ea/svg/ea_emitter/render_context.rb +14 -3
  32. data/lib/ea/svg/ea_emitter/segment_geometry.rb +123 -0
  33. data/lib/ea/svg/ea_emitter.rb +6 -0
  34. data/lib/ea/version.rb +1 -1
  35. metadata +8 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0c2231b8d009e8d99c841920776cbe8539e09b76d512beed109f073fb7904282
4
- data.tar.gz: 3feade927368b299842047c9c0e31aa0cb8473e7dad6870e5626fb369e1503de
3
+ metadata.gz: 9db50e16de6c9f9faa698a872686600da15ba3eef0787de0b18b0ea56eca0394
4
+ data.tar.gz: b537f2b76e1fdb3a5a6dcfd1581db9f651279c5aff4e35882144da2ace23cf26
5
5
  SHA512:
6
- metadata.gz: 025f36b456316a6fcd801318f17c27363d62d3ee7355cd303c1b7147081f44d3b70fdf711966ae4a5a7fc33e022dc097ba4d6f15f6c3ad03e237d59318b89fac
7
- data.tar.gz: d2487edce57559735c9cdf42984dd16ad3597985fb5d2f01ddeb9289c893b423a986f8587b3788d0cd759aaca93661c8e0a86262a4939f503d21e9495e29db9f
6
+ metadata.gz: 9a685c1155369f6800fbfd294feed39ea2b450f482245c30be940c18bb31098466be6dbb0f0e6ca28e90502798ceaf10c8a5bbac0f0739b619b1cc0cd69696cb
7
+ data.tar.gz: 570de8eaf44e482c0330cc96f17eb2ab89e26a75542c613617b53468040b45dfec9ce597b308cd50b068d0a78e20e415a9cda9e897622f1cc1b1ab2a4e110baa
@@ -59,6 +59,8 @@ module Ea
59
59
  File.expand_path("cjk_fallback_widths.json", __dir__)
60
60
  )).fetch("widths").freeze
61
61
  CJK_FALLBACK_FAMILIES = %w[Carlito Cambria].freeze
62
+ # Logical height from which a bold Cambria CJK run uses regular advances.
63
+ CAMBRIA_REGULAR_BOLD_MIN_HEIGHT = 17
62
64
  # Carlito ASCII advances measured on Windows per "height:weight:style";
63
65
  # they take precedence over the quantized design advances.
64
66
  CARLITO_GDI_WIDTHS = JSON.parse(File.read(
@@ -71,7 +73,7 @@ module Ea
71
73
  )).fetch("advance").freeze
72
74
  private_constant :CAMBRIA_7_REGULAR_WIDTHS, :CAMBRIA_7_STYLED_WIDTHS,
73
75
  :CAMBRIA_7_GUILLEMET_PAIR_WIDTH, :VERTICAL_METRICS,
74
- :CJK_FALLBACK_WIDTHS, :CJK_FALLBACK_FAMILIES,
76
+ :CJK_FALLBACK_WIDTHS, :CJK_FALLBACK_FAMILIES, :CAMBRIA_REGULAR_BOLD_MIN_HEIGHT,
75
77
  :CARLITO_GDI_WIDTHS, :SOURCE_SANS_PRO_ADVANCES
76
78
 
77
79
  # Advance used for codepoints missing from the tables.
@@ -205,7 +207,7 @@ module Ea
205
207
  private_class_method :measured_carlito_widths
206
208
 
207
209
  def cambria_text_length(text, size_pt, weight, style)
208
- return nil unless size_pt.to_f.to_r == 7
210
+ return cambria_cjk_only_length(text, size_pt, weight, style) unless size_pt.to_f.to_r == 7
209
211
  return nil unless ["", "0", "400", "normal", "700", "bold"].include?(weight.to_s)
210
212
  return nil unless ["", "normal", "italic"].include?(style.to_s)
211
213
 
@@ -220,6 +222,24 @@ module Ea
220
222
  end
221
223
  private_class_method :cambria_text_length
222
224
 
225
+ # Cambria has no Latin table outside 7pt, but a run made only of
226
+ # characters in the measured MS UI Gothic table is exact at any
227
+ # measured height. EMPIRICAL FIT (decision a): EA draws a bold
228
+ # Cambria CJK run at logical height >= 17 with the regular-weight
229
+ # advances (no synthetic-bold pixel), although the Windows table
230
+ # records one. Checked on the 116 native legend titles "凡例"
231
+ # (Cambria bold 12pt, H17): native textLength 34 = 2 x 17, where
232
+ # the 17:700 entry would give 36. Below H17 bold keeps the bold
233
+ # table (native 7pt bold "都市計画データ" = 70).
234
+ def cambria_cjk_only_length(text, size_pt, weight, style)
235
+ height = gdi_logical_height(size_pt)
236
+ bold = bold?(weight) && height < CAMBRIA_REGULAR_BOLD_MIN_HEIGHT
237
+ widths = cjk_fallback_widths("Cambria", height, bold ? 700 : 400, style)
238
+ advances = text.each_char.map { |char| widths[char] }
239
+ advances.sum unless advances.include?(nil)
240
+ end
241
+ private_class_method :cambria_cjk_only_length
242
+
223
243
  def cambria_cjk_widths(weight, style)
224
244
  cjk_fallback_widths("Cambria", gdi_logical_height(7), weight, style)
225
245
  end
data/lib/ea/model/note.rb CHANGED
@@ -15,6 +15,7 @@ module Ea
15
15
  attribute :body, :string
16
16
  attribute :note_type, :string # "Note" | "Text" | "File"
17
17
  attribute :legend, Legend
18
+ attribute :package_id, :string # containing package
18
19
 
19
20
  json do
20
21
  map "id", to: :id
@@ -22,6 +23,7 @@ module Ea
22
23
  map "body", to: :body
23
24
  map "noteType", to: :note_type
24
25
  map "legend", to: :legend
26
+ map "packageId", to: :package_id
25
27
  end
26
28
  end
27
29
  end
@@ -6,6 +6,8 @@ module Ea
6
6
  class DiagramBuilder
7
7
  # Internal geometry for choosing the facing edges of stored routes.
8
8
  module Routing
9
+ REVERSED_TREE_BUS_OFFSET = 50
10
+
9
11
  private
10
12
 
11
13
  # Offsets stay attached to semantic Start/End IDs even when the
@@ -32,6 +34,100 @@ module Ea
32
34
  model
33
35
  end
34
36
 
37
+ # EMPIRICAL FIT (decision a), not a source-backed rule. A width-0
38
+ # Mode 3 vertical-tree Association whose saved SOID/EOID name the
39
+ # relationship's ends in reverse keeps a Path (bus height and
40
+ # bend order) that EA no longer draws: its native export routes
41
+ # from the semantic Start box to the End box, with the bus 50px
42
+ # beyond the End box's facing edge (the distance every stored tree
43
+ # bus in the corpus keeps from its End box, 600+ links), the Start
44
+ # leg at center + SX and the End leg at center + EX. Checked on
45
+ # plateau diagram 5038EEE3 connectors 208, 209 and 212 (exact);
46
+ # 211 and 902 follow the leg pull documented on reversed_tree_leg.
47
+ def reversed_tree_route(model, link_row, diagram_row, geom, context)
48
+ return model if context || !reversed_tree_link?(model, link_row, diagram_row, geom)
49
+
50
+ start, finish = stored_ray_boxes(link_row, diagram_row)
51
+ bus, start_edge, finish_edge = reversed_tree_levels(start, finish)
52
+ return model unless bus
53
+
54
+ first = reversed_tree_leg(start, geom[:sx], geom[:sy], start_edge, finish)
55
+ last = reversed_tree_leg(finish, geom[:ex], geom[:ey], finish_edge, start)
56
+ model.waypoints = [first, [first[0], bus], [last[0], bus], last].map do |x, y|
57
+ Ea::Model::Waypoint.new(position: Ea::Model::Point.new(x: x, y: y))
58
+ end
59
+ model.source_anchor = model.source_port = model.waypoints.first.position
60
+ model.target_anchor = model.target_port = model.waypoints.last.position
61
+ # EMPIRICAL FIT: a leg that starts beside its box (5038EEE3
62
+ # connector 212) seats its source labels like a leftward leg.
63
+ model.style = model.style.to_h.merge(left_seated_source: true) if first[1] != start_edge
64
+ model
65
+ end
66
+
67
+ # EMPIRICAL FIT (decision a). EA seats the source labels of a
68
+ # vertical lateral tree ("LV") whose SOID/EOID name the ends in
69
+ # reverse as if the source leg left to the left, whichever way the
70
+ # route leaves the box. Measured on the only such link in the
71
+ # plateau corpus, 5038EEE3 connector 207 (+lod1MultiSurface).
72
+ def mark_reversed_lateral_tree(model, link_row, diagram_row, context)
73
+ return model if context
74
+
75
+ style = DiagramStyleParser.parse(link_row.style)
76
+ return model unless style[:tree] == "LV" && model.connector_type == "Association"
77
+ return model unless placement_order_reversed?(style, diagram_row,
78
+ database.find_connector(link_row.connectorid))
79
+
80
+ model.style = model.style.to_h.merge(left_seated_source: true)
81
+ model
82
+ end
83
+
84
+ def reversed_tree_link?(model, link_row, diagram_row, geom)
85
+ style = DiagramStyleParser.parse(link_row.style)
86
+ model.connector_type == "Association" &&
87
+ style.values_at(:mode, :lwidth, :tree) == %w[3 0 V] &&
88
+ geom.values_at(:sx, :sy, :ex, :ey).all? &&
89
+ stored_bus_levels(link_row) &&
90
+ placement_order_reversed?(style, diagram_row, database.find_connector(link_row.connectorid))
91
+ end
92
+
93
+ def stored_bus_levels(link_row)
94
+ bends = intermediate_waypoints(link_row)
95
+ bends.size == 2 && bends.first[1] == bends.last[1]
96
+ end
97
+
98
+ # [bus height, Start facing edge, End facing edge] for vertically
99
+ # separated boxes: the bus sits 50px past the End box's edge and
100
+ # must stay inside the gap. Nil otherwise.
101
+ def reversed_tree_levels(start, finish)
102
+ if finish.y + finish.height < start.y
103
+ bus = finish.y + finish.height + REVERSED_TREE_BUS_OFFSET
104
+ [bus, start.y, finish.y + finish.height] if bus < start.y
105
+ elsif start.y + start.height < finish.y
106
+ bus = finish.y - REVERSED_TREE_BUS_OFFSET
107
+ [bus, start.y + start.height, finish.y] if bus > start.y + start.height
108
+ end
109
+ end
110
+
111
+ # A leg runs vertically at the stored origin's x. It starts on the
112
+ # facing edge, unless the origin lies beside the box (outside its
113
+ # x-span but level with it), where the origin itself is kept.
114
+ #
115
+ # EMPIRICAL FIT (decision a): the leg's x is pulled toward the box
116
+ # centre by |offset_y| / (vertical centre distance to the other box).
117
+ # Checked on 5038EEE3 connectors 211 (SX 41, SY -125: native 13px
118
+ # left of centre+SX), 902 (SX 64, SY -13: 2px) and 212, 208, 209
119
+ # (SY within 5: the rounded-up pull leaves centre + SX unchanged);
120
+ # the offset is rounded away from zero, as all five require.
121
+ # Derived from these five SX/SY pairs, not from EA documentation.
122
+ def reversed_tree_leg(box, offset_x, offset_y, edge_y, other)
123
+ span = ((other.y + other.height / 2.0) - (box.y + box.height / 2.0)).abs
124
+ pull = span.positive? ? 1 - [offset_y.to_i.abs / span, 1].min : 1
125
+ pulled = (offset_x.to_i.abs * pull).ceil * (offset_x.to_i.negative? ? -1 : 1)
126
+ origin = stored_ray_origin(box, pulled, offset_y)
127
+ beside = !origin.x.between?(box.x, box.x + box.width) && origin.y.between?(box.y, box.y + box.height)
128
+ [origin.x, beside ? origin.y : edge_y]
129
+ end
130
+
35
131
  # Path follows SOID/EOID placement order, independently of the
36
132
  # relationship's Start/End IDs. Only a complete, unique DUID pair
37
133
  # can reverse the authored single-corner route.
@@ -42,6 +138,10 @@ module Ea
42
138
  parse_geometry_fields(link_row.geometry)[:edge] == 2 &&
43
139
  intermediate_waypoints(link_row).size == 1
44
140
 
141
+ placement_order_reversed?(style, diagram_row, connector)
142
+ end
143
+
144
+ def placement_order_reversed?(style, diagram_row, connector)
45
145
  ids = style.values_at(:soid, :eoid)
46
146
  return false if ids.any? { |id| id.to_s.empty? } || ids.uniq.size != 2
47
147
 
@@ -695,8 +795,7 @@ module Ea
695
795
  return nil
696
796
  end
697
797
 
698
- first = outline_dock(corners[0], corners[1], source)
699
- last = outline_dock(corners[-1], corners[-2], target)
798
+ first, last = stair_docks(link_row, corners, source, target)
700
799
  return nil unless distinct_stair_docks?(corners, first, last)
701
800
 
702
801
  [first, *corners, last]
@@ -728,6 +827,45 @@ module Ea
728
827
  :horizontal if from[1] == to[1] && from[0] != to[0]
729
828
  end
730
829
 
830
+ def stair_docks(link_row, corners, source, target)
831
+ geom = parse_geometry_fields(link_row.geometry)
832
+ [stair_dock(corners[0], corners[1], source, geom.values_at(:sx, :sy)),
833
+ stair_dock(corners[-1], corners[-2], target, geom.values_at(:ex, :ey))]
834
+ end
835
+
836
+ # A corner level with its box docks on the outline the leg points at.
837
+ # When the stored origin sits off that row, EA aims the last leg at
838
+ # the origin instead and the dock slants onto the same outline
839
+ # (30D62231 connector 601: bend row 430, origin row 425, dock row 428).
840
+ def stair_dock(corner, neighbour, box, offsets)
841
+ dock = outline_dock(corner, neighbour, box)
842
+ return dock unless dock && offsets.none?(&:nil?)
843
+
844
+ origin = stored_ray_origin(box, *offsets)
845
+ return dock unless inside_box?(origin, grown_bounds(box, 1))
846
+
847
+ slanted_dock([origin.x, origin.y], corner, dock) || dock
848
+ end
849
+
850
+ # Where the line from the stored origin to the corner meets the
851
+ # outline row/column of the orthogonal dock; the other axis floors.
852
+ def slanted_dock(origin, corner, dock)
853
+ axis = dock[0] == corner[0] ? 1 : 0
854
+ other = intercept(origin, corner, axis, dock[axis])
855
+ return unless other
856
+
857
+ axis.zero? ? [dock[0], other] : [other, dock[1]]
858
+ end
859
+
860
+ def intercept(origin, corner, axis, face)
861
+ run = corner[axis] - origin[axis]
862
+ fraction = Rational(face - origin[axis], run) unless run.zero?
863
+ return unless fraction&.between?(0, 1)
864
+
865
+ other = 1 - axis
866
+ (origin[other] + fraction * (corner[other] - origin[other])).floor
867
+ end
868
+
731
869
  # The dock runs perpendicular to the corner's own leg.
732
870
  def outline_dock(corner, neighbour, box)
733
871
  if corner[1] == neighbour[1]
@@ -128,6 +128,7 @@ module Ea
128
128
  @current_obj_row = obj_row
129
129
  style_hash = DiagramStyleParser.parse(obj_row.objectstyle)
130
130
  style_hash[:stored_attribute_order] = true
131
+ style_hash["connector_proxy"] = true if connector_proxy?(obj_row)
131
132
  fqname = stereotype_fqname_for(obj_row)
132
133
  style_hash[:stereotype_fqname] = fqname if fqname
133
134
  font_style = effective_font_style(obj_row, style_hash)
@@ -154,6 +155,12 @@ module Ea
154
155
  )
155
156
  end
156
157
 
158
+ # EA anchors association-class links on ProxyConnector stubs and
159
+ # never draws them (the XMI source flags them the same way).
160
+ def connector_proxy?(obj_row)
161
+ database.find_object(obj_row.ea_object_id)&.object_type == "ProxyConnector"
162
+ end
163
+
157
164
  # Negative widths inherit; QEA zero widths request a one-pixel cosmetic pen.
158
165
  def effective_line_width(obj_row, style)
159
166
  object = database.find_object(obj_row.ea_object_id)
@@ -475,6 +482,7 @@ module Ea
475
482
  style: context ? { direct: true } :
476
483
  DiagramStyleParser.parse(link_row.style),
477
484
  hidden: hidden?(link_row),
485
+ line_width: connector&.isbold,
478
486
  ghost_labels: ghost_labels_for(connector, diagram_row, link_row),
479
487
  has_geometry_offsets: geometry_has_offsets?(link_row.geometry),
480
488
  is_substitutable: substitutable_connector?(connector)
@@ -494,6 +502,8 @@ module Ea
494
502
  model = mark_default_square_ray(model, link_row, geom, context)
495
503
  model = restore_stored_square_ports(model, link_row, diagram_row, geom, context)
496
504
  model = reversed_stored_corner(model, link_row, diagram_row, geom, context)
505
+ model = reversed_tree_route(model, link_row, diagram_row, geom, context)
506
+ model = mark_reversed_lateral_tree(model, link_row, diagram_row, context)
497
507
  mark_association_class(model, connector, diagram_row)
498
508
  end
499
509
 
@@ -38,10 +38,16 @@ module Ea
38
38
  name: obj.name,
39
39
  body: obj.note,
40
40
  note_type: obj.object_type,
41
- legend: legend_for(obj)
41
+ legend: legend_for(obj),
42
+ package_id: package_id_for(obj)
42
43
  )
43
44
  end
44
45
 
46
+ def package_id_for(obj)
47
+ pkg = database.find_package(obj.package_id)
48
+ pkg && IdNormalizer.from_guid(pkg.ea_guid)
49
+ end
50
+
45
51
  def note_type?(object_type)
46
52
  object_type == NOTE_OBJECT_TYPE || object_type == TEXT_OBJECT_TYPE
47
53
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Sources
5
+ module Xmi
6
+ class DiagramBuilder
7
+ # An element placed without its own LWth takes the border width saved on
8
+ # the model element (<style appearance="...BorderWidth=n;...">), the same
9
+ # t_object.BorderWidth fallback the QEA source applies. Checked on
10
+ # AB281AC1, where the missing width left the Association 1px long.
11
+ class ElementBorders
12
+ def initialize(root)
13
+ @elements = root.extension&.elements&.element.to_a.to_h { |element| [element.idref, element] }
14
+ end
15
+
16
+ def width_for(subject)
17
+ appearance = @elements[subject]&.style&.appearance.to_s
18
+ raw = ExtensionStyleParser.split_pairs(appearance)["BorderWidth"]
19
+ return nil if raw.nil? || raw.to_i.negative?
20
+
21
+ [raw.to_i, 1].max
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -12,6 +12,7 @@ module Ea
12
12
  autoload :ConnectorMetadata, "ea/sources/xmi/diagram_builder/connector_metadata"
13
13
  autoload :AssociationClassMetadata, "ea/sources/xmi/diagram_builder/association_class_metadata"
14
14
  autoload :FontSettings, "ea/sources/xmi/diagram_builder/font_settings"
15
+ autoload :ElementBorders, "ea/sources/xmi/diagram_builder/element_borders"
15
16
  autoload :StereotypeProfiles, "ea/sources/xmi/diagram_builder/stereotype_profiles"
16
17
  autoload :UmldiEdges, "ea/sources/xmi/diagram_builder/umldi_edges"
17
18
 
@@ -135,7 +136,7 @@ module Ea
135
136
  image_bounds: (build_bounds(geom.img_left, geom.img_top, geom.img_right, geom.img_bottom) unless bounds),
136
137
  background_color: style.background_color,
137
138
  line_color: style.line_color,
138
- line_width: style.line_width,
139
+ line_width: placed_line_width(style, placed.subject),
139
140
  font_family: font[:family],
140
141
  font_size: font[:size],
141
142
  font_size_unit: "pt",
@@ -155,6 +156,17 @@ module Ea
155
156
 
156
157
  # XMI preserves UML member order in ownedAttribute. Carry that
157
158
  # provenance to the renderer so it does not replace it with a name sort.
159
+ # LWth on the placement, else the model element's saved border width.
160
+ # A negative LWth (-1) inherits; zero requests the one-pixel cosmetic
161
+ # pen, as in the QEA source. Checked on 01661E1B (LWth=-1 over
162
+ # BorderWidth=2) and 1A10EB6D (LWth=0, native stroke-width 1).
163
+ def placed_line_width(style, subject)
164
+ own = style.line_width
165
+ return [own, 1].max if own && own >= 0
166
+
167
+ (@element_borders ||= ElementBorders.new(root)).width_for(subject)
168
+ end
169
+
158
170
  def element_style(subject)
159
171
  style = (@stereotype_profiles ||= StereotypeProfiles.new(root)).for(subject)
160
172
  return style unless source_ordered_classifiers.include?(subject)
@@ -179,8 +191,8 @@ module Ea
179
191
  return nil unless left && top && right && bottom
180
192
 
181
193
  Ea::Model::Bounds.new(x: left, y: top,
182
- width: right - left,
183
- height: bottom - top)
194
+ width: right - left,
195
+ height: bottom - top)
184
196
  end
185
197
 
186
198
  def build_connectors(placed_connectors, placed_elements, owner_id, context)
@@ -245,7 +257,7 @@ module Ea
245
257
  waypoints: route[:waypoints],
246
258
  style: route[:style],
247
259
  line_color: style.color,
248
- line_width: style.line_width,
260
+ line_width: ext_meta[:line_width],
249
261
  hidden: style.hidden,
250
262
  label_boxes: connector_label_boxes(placed, ext_meta[:ea_type],
251
263
  options.fetch(:context) ? nil : owner_id)
@@ -257,7 +269,10 @@ module Ea
257
269
  connector.label_boxes)
258
270
  end
259
271
  mark_stored_ray(connector, placed, source, target) unless options.fetch(:context)
260
- mark_association_route(connector, placed, source, target) unless options.fetch(:context)
272
+ unless options.fetch(:context)
273
+ reversed = reversed_placement?(style, by_duid, edge_source, edge_target)
274
+ mark_association_route(connector, placed, source, target, reversed)
275
+ end
261
276
  connector
262
277
  end
263
278
 
@@ -280,7 +295,9 @@ module Ea
280
295
 
281
296
  metadata = stored_ray_metadata(connector, placed, source, target)
282
297
  metadata.each { |name, value| connector.public_send("#{name}=", value) }
283
- connector.style[:association_ray_outline] = true unless metadata.empty?
298
+ return if metadata.empty? || connector.connector_type == "Generalization"
299
+
300
+ connector.style[:association_ray_outline] = true
284
301
  end
285
302
 
286
303
  def stored_ray_metadata(connector, placed, source, target, stage: :ray_metadata)
@@ -291,9 +308,20 @@ module Ea
291
308
  StoredRoute.new.public_send(stage, connector, link: link, bounds: bounds, offsets: offsets, edge: geom.edge)
292
309
  end
293
310
 
294
- def mark_association_route(connector, placed, source, target)
311
+ def mark_association_route(connector, placed, source, target, reversed)
312
+ connector.style[:reversed_ends] = true if reversed
295
313
  metadata = stored_ray_metadata(connector, placed, source, target, stage: :association_route)
296
314
  metadata.each { |name, value| connector.public_send("#{name}=", value) }
315
+ connector.style.delete(:reversed_ends)
316
+ end
317
+
318
+ # Mirrors QEA placement_order_reversed?: SOID and EOID must both name
319
+ # placements on this diagram and be crossed against the relationship's
320
+ # ends. A stale DUID resolves to nothing and is not a reversal.
321
+ def reversed_placement?(style, by_duid, edge_source, edge_target)
322
+ source = by_duid[style.soid]
323
+ target = by_duid[style.eoid]
324
+ source && target && reversed_ends?(source, target, edge_source, edge_target)
297
325
  end
298
326
 
299
327
  def mark_plain_source_seat(connector, placed, context)
@@ -356,12 +384,13 @@ module Ea
356
384
 
357
385
  def connector_meta(subject_id)
358
386
  connector = connector_index[subject_id]
359
- return { ea_type: nil, direction: nil } unless connector
387
+ return { ea_type: nil, direction: nil, line_width: nil } unless connector
360
388
 
361
389
  props = connector.properties
362
390
  {
363
391
  ea_type: props&.ea_type,
364
- direction: props&.direction
392
+ direction: props&.direction,
393
+ line_width: connector.appearance&.linewidth
365
394
  }.merge(connector_metadata.for(connector))
366
395
  end
367
396
 
@@ -513,8 +542,8 @@ module Ea
513
542
  return nil unless g.left && g.top && g.right && g.bottom
514
543
 
515
544
  Ea::Model::Bounds.new(x: g.left, y: g.top,
516
- width: g.right - g.left,
517
- height: g.bottom - g.top)
545
+ width: g.right - g.left,
546
+ height: g.bottom - g.top)
518
547
  end
519
548
 
520
549
  def edge_point(placed, edge_code, _end_kind)
@@ -40,7 +40,8 @@ module Ea
40
40
  name: ext_element.name,
41
41
  body: ext_element.properties&.documentation,
42
42
  note_type: note_type,
43
- legend: legend_for(ext_element, note_type)
43
+ legend: legend_for(ext_element, note_type),
44
+ package_id: IdNormalizer.from_xmi_id(ext_element.model&.package)
44
45
  )
45
46
  end
46
47
 
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Sources
5
+ module Xmi
6
+ class StoredRoute < Qea::DiagramBuilder
7
+ # QEA's reversed-end Association rules for XMI. A connector is reversed
8
+ # when its SOID names a placement other than the relationship's source
9
+ # (builder flag :reversed_ends). Geometry helpers come from
10
+ # Qea::DiagramBuilder::Routing, so both sources share one rule.
11
+ module ReversedRoutes
12
+ private
13
+
14
+ # A width-0 vertical-tree Association takes the bus 50px past the End
15
+ # box's facing edge with legs at the stored origins (an empirical fit,
16
+ # see Routing#reversed_tree_route; checked on 5038EEE3).
17
+ def reversed_tree_metadata(model, link, bounds, offsets, edge)
18
+ return {} unless reversed_tree_candidate?(model, link, bounds, offsets)
19
+
20
+ start, finish = bounds.values_at(:source, :target)
21
+ bus, start_edge, finish_edge = reversed_tree_levels(start, finish)
22
+ return {} unless bus
23
+
24
+ geom = ray_geometry(offsets, edge)
25
+ first = reversed_tree_leg(start, geom[:sx], geom[:sy], start_edge, finish)
26
+ last = reversed_tree_leg(finish, geom[:ex], geom[:ey], finish_edge, start)
27
+
28
+ reversed_tree_fields(model, first, last, bus, first[1] != start_edge)
29
+ end
30
+
31
+ def reversed_tree_fields(model, first, last, bus, beside)
32
+ style = model.style.to_h.except(:umldi_route, :reversed_ends)
33
+ style[:left_seated_source] = true if beside
34
+ ends = [point_for(first), point_for(last)]
35
+ { waypoints: points_to_waypoints([first, [first[0], bus], [last[0], bus], last]),
36
+ source_anchor: ends[0], target_anchor: ends[1], source_port: ends[0], target_port: ends[1],
37
+ style: style }
38
+ end
39
+
40
+ def reversed_tree_candidate?(model, link, bounds, offsets)
41
+ model.connector_type == "Association" && model.style[:reversed_ends] &&
42
+ model.style.to_h.values_at(:mode, :lwidth, :tree) == %w[3 0 V] &&
43
+ ray_inputs?(bounds, offsets) && stored_bus_levels(link)
44
+ end
45
+
46
+ # A thin Mode 3 EDGE 2 Association whose single saved corner follows
47
+ # the opposite placement order is clipped from each leg's stored
48
+ # origin, as QEA's reversed_stored_corner does (5038EEE3).
49
+ def reversed_corner_metadata(model, link, bounds, offsets, edge)
50
+ return {} unless reversed_corner_candidate?(model, link, bounds, offsets, edge)
51
+
52
+ boxes = bounds.values_at(:source, :target)
53
+ origins = corner_origins(boxes, ray_geometry(offsets, edge))
54
+ bend = Ea::Model::Point.new(**%i[x y].zip(intermediate_waypoints(link).first).to_h)
55
+ ends = origins.zip(boxes).map { |origin, box| corner_exit(origin, bend, box) }
56
+ ends.all? ? corner_fields(origins, bend, ends) : {}
57
+ end
58
+
59
+ def corner_fields(origins, bend, ends)
60
+ { waypoints: points_to_waypoints([ends[0], bend, ends[1]].map { |point| [point.x, point.y] }),
61
+ source_anchor: origins[0], target_anchor: origins[1] }
62
+ end
63
+
64
+ def corner_origins(boxes, geom)
65
+ [stored_ray_origin(boxes[0], geom[:sx], geom[:sy]), stored_ray_origin(boxes[1], geom[:ex], geom[:ey])]
66
+ end
67
+
68
+ def corner_exit(origin, bend, box)
69
+ inside_box?(origin, box) ? diagonal_corner_exit(origin, bend, box) : origin
70
+ end
71
+
72
+ def reversed_corner_candidate?(model, link, bounds, offsets, edge)
73
+ model.connector_type == "Association" && model.style[:reversed_ends] && edge == 2 &&
74
+ model.style.to_h.values_at(:mode, :lwidth, :tree) == ["3", "0", nil] &&
75
+ ray_inputs?(bounds, offsets) && intermediate_waypoints(link).size == 1
76
+ end
77
+
78
+ # QEA seats the source labels of a reversed vertical lateral tree
79
+ # ("LV") as if its source leg left to the left (5038EEE3 connector 207).
80
+ def reversed_lateral_tree_metadata(model, _link, _bounds, _offsets, _edge)
81
+ return {} unless model.connector_type == "Association" && model.style[:reversed_ends] &&
82
+ model.style[:tree] == "LV"
83
+
84
+ { style: model.style.to_h.except(:reversed_ends).merge(left_seated_source: true) }
85
+ end
86
+
87
+ def points_to_waypoints(points)
88
+ points.map { |x, y| Ea::Model::Waypoint.new(position: Ea::Model::Point.new(x: x, y: y)) }
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end