ea 0.6.100 → 0.6.102

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: 8f60cf58bb521e911eb26fb30b628162e149a9521027fae0e77756ef1b8354dd
4
- data.tar.gz: c0580ced4122e41650fbc5053fb0fb534a40c8cae84f758039dd8b2f575422ad
3
+ metadata.gz: c453170a8a782786ce0a1061623c8de93ed75536228bb99d9b6c1857163f58f3
4
+ data.tar.gz: 19c5dc1e01e47508e51dfc87add4cd24059b4035b83ed45c5aa98d46d4edcb8b
5
5
  SHA512:
6
- metadata.gz: 6460bd84357b97c350c485526bcc62bc74309d924eacd9e7983347b101c22d1fcfc246dc7b76d443eb04cb360e2be890eaf1cffedf49103e9f2419b5205fe3a7
7
- data.tar.gz: 2a8ce3556ffa2b5531e432002ab7a67439cd4f4ed171c1f84f141b264e9fa6913af3ffd8983dbb0be70c9282407fdb3695efb07728c0217c656688ed38f8d023
6
+ metadata.gz: 26fbc91c4d39c834e0e9a19c443501953b487210ea88922be813bf167dfb56c6d2cc55cb3238441f58a08a6cd7511560e495f787a05a566466e9e7c5f45b06b0
7
+ data.tar.gz: 79f6d211068cf179e2fee6eea24e2e0ae60385be826db2254dcb6ce3d3aab345d68c743f529d430b242f8ea0cc3fdda7425e6cf880cf984abf1b7aeec145d74c
@@ -23,6 +23,16 @@ module Ea
23
23
  set_stored_ray_origins(model, source, target, geom)
24
24
  end
25
25
 
26
+ def mark_default_square_ray(model, link_row, geom, context)
27
+ style = model.style.to_h
28
+ return model if context || !link_row.path.to_s.empty? || model.waypoints.size != 2
29
+ return model unless style[:mode] == "3" && style[:tree] == "OS" && style[:lwidth] == "2"
30
+ return model unless geom[:sx].nil? && geom[:sy].nil? && [geom[:ex], geom[:ey]] == [0, 0]
31
+
32
+ model.style = style.merge(default_square_ray: true)
33
+ model
34
+ end
35
+
26
36
  # A Mode 2 Generalization whose stored Path is a horizontal jog
27
37
  # between vertically separated boxes that overlap in x is EA's
28
38
  # auto-route: one vertical ray at the overlap midpoint plus SX.
@@ -100,40 +110,108 @@ module Ea
100
110
  return model unless stored_ray_candidate?(model, link_row, geom)
101
111
 
102
112
  source, target = stored_ray_boxes(link_row, diagram_row)
103
- return model unless stored_ray_gap?(source, target, model.style[:mode], geom[:edge])
113
+ allowed = if normalized_port_anchors?(model)
114
+ normalized_level_ray?(model, source, target, geom)
115
+ else
116
+ stored_ray_gap?(source, target, model.style[:mode], geom[:edge])
117
+ end
118
+ return model unless allowed
104
119
 
105
120
  set_stored_ray_origins(model, source, target, geom)
106
121
  end
107
122
 
108
- # An empty-Path Mode 3 Association with explicit offsets on both
109
- # ends is the straight line between the two stored ray origins.
110
- # Origins outside their own box are not a stored ray.
123
+ # An empty-Path Mode 3 Association follows the line between its
124
+ # stored endpoint origins; an omitted offset defaults to the center.
125
+ # The target origin must lie inside its own stroked outline.
111
126
  def association_stored_ray(model, link_row, diagram_row, geom, context)
112
127
  return model if context
113
128
  return model unless association_ray_candidate?(model, link_row)
114
129
 
115
130
  source, target = stored_ray_boxes(link_row, diagram_row)
116
- origins = association_ray_origins(source, target, geom)
131
+ width = association_outline_width(model, link_row, diagram_row)
132
+ origins = association_ray_origins(source, target, geom, width)
117
133
  return model unless origins
118
134
 
119
- model.waypoints = [model.source_port, model.target_port].map do |point|
120
- Ea::Model::Waypoint.new(position: point)
121
- end
135
+ model.waypoints = port_waypoints(model, origins)
122
136
  model.source_anchor, model.target_anchor = origins
137
+ model.style = model.style.to_h.merge(stored_association_ray: true, association_ray_outline: true)
138
+ if geom[:edge] == 1 && !inside_box?(origins.first, grown_bounds(source, width == "2" ? 1 : 0))
139
+ model.style[:stored_top_dock] = true
140
+ end
123
141
  model
124
142
  end
125
143
 
126
- def association_ray_origins(source, target, geom)
144
+ def port_waypoints(model, origins)
145
+ [model.source_port || origins.first, model.target_port || origins.last].map do |point|
146
+ Ea::Model::Waypoint.new(position: point)
147
+ end
148
+ end
149
+
150
+ # Width-2 boxes also own their 1px outer stroke, so a stored
151
+ # origin on that outline is still inside its box.
152
+ def association_ray_origins(source, target, geom, width)
153
+ outline = width == "2" ? 1 : 0
127
154
  origins = [stored_ray_origin(source, geom[:sx], geom[:sy]),
128
155
  stored_ray_origin(target, geom[:ex], geom[:ey])]
129
- inside = origins.zip([source, target]).all? { |o, b| inside_box?(o, b) }
130
- origins if inside && sub_pixel_safe_origins?(origins, [source, target])
156
+ boxes = [source, target].map { |box| grown_bounds(box, outline) }
157
+ return unless origins.first != origins.last && association_origin_pair?(origins, boxes, width, geom[:edge])
158
+
159
+ origins if width == "2" || sub_pixel_safe_origins?(origins, [source, target] + boxes)
160
+ end
161
+
162
+ # An external source origin is a virtual point on the saved ray.
163
+ # It is usable only when that segment actually crosses the source.
164
+ def association_origin_pair?(origins, boxes, width, edge)
165
+ return false unless inside_box?(origins.last, boxes.last)
166
+ return true if inside_box?(origins.first, boxes.first)
167
+
168
+ return segment_crosses_box?(origins.first, origins.last, boxes.first) if width == "2"
169
+ return false unless edge == 1
170
+
171
+ source = boxes.first
172
+ top = Ea::Model::Bounds.new(x: source.x, y: source.y, width: source.width, height: 0)
173
+ segment_crosses_box?(origins.first, origins.last, top)
174
+ end
175
+
176
+ def segment_crosses_box?(first, last, box)
177
+ interval = [0.0, 1.0]
178
+ axes = [[first.x, last.x, box.x, box.x + box.width],
179
+ [first.y, last.y, box.y, box.y + box.height]]
180
+ axes.all? do |origin, finish, low, high|
181
+ if origin == finish
182
+ origin.between?(low, high)
183
+ else
184
+ times = [(low - origin).to_f / (finish - origin),
185
+ (high - origin).to_f / (finish - origin)].minmax
186
+ interval = [[interval.first, times.first].max, [interval.last, times.last].min]
187
+ interval.first <= interval.last
188
+ end
189
+ end
190
+ end
191
+
192
+ def grown_bounds(bounds, amount)
193
+ Ea::Model::Bounds.new(x: bounds.x - amount, y: bounds.y - amount,
194
+ width: bounds.width + 2 * amount,
195
+ height: bounds.height + 2 * amount)
131
196
  end
132
197
 
133
198
  def association_ray_candidate?(model, link_row)
134
199
  model.connector_type == "Association" && link_row.path.to_s.empty? &&
135
- model.has_geometry_offsets && model.source_port && model.target_port &&
136
- association_ray_style?(model.style.to_h)
200
+ model.source_anchor && model.target_anchor &&
201
+ (model.source_port || model.target_port) && association_ray_style?(model.style.to_h)
202
+ end
203
+
204
+ # A thin connector still meets the outer stroke of explicitly
205
+ # bordered elements. Ambiguous placements must agree on that border.
206
+ def association_outline_width(model, link_row, diagram_row)
207
+ width = model.style[:lwidth]
208
+ return width if width == "2"
209
+
210
+ connector = database.find_connector(link_row.connectorid)
211
+ ids = [connector.start_object_id, connector.end_object_id]
212
+ placements = database.diagram_objects_for(diagram_row.diagram_id)
213
+ borders = placements.select { |placement| ids.include?(placement.ea_object_id) }
214
+ borders.all? { |placement| DiagramStyleParser.parse(placement.objectstyle)[:lwth] == "2" } ? "2" : width
137
215
  end
138
216
 
139
217
  def association_ray_style?(style)
@@ -141,15 +219,13 @@ module Ea
141
219
  %i[direct regenerated].none? { |key| style.key?(key) }
142
220
  end
143
221
 
144
- # Origins are whole pixels while EA's box centers may sit on a
145
- # half pixel. An origin on its border then clips differently
222
+ # Width-0 origins are whole pixels while EA's box centers may sit
223
+ # on a half pixel. An origin on its border then clips differently
146
224
  # unless the line is axis-aligned, which no rounding changes.
147
225
  def sub_pixel_safe_origins?(origins, boxes)
148
226
  first, last = origins
149
- return false if first == last
150
-
151
227
  axis_aligned = first.x == last.x || first.y == last.y
152
- axis_aligned || origins.zip(boxes).none? { |point, box| on_border?(point, box) }
228
+ axis_aligned || origins.none? { |point| boxes.any? { |box| on_border?(point, box) } }
153
229
  end
154
230
 
155
231
  def on_border?(point, bounds)
@@ -248,7 +324,21 @@ module Ea
248
324
  def stored_ray_candidate?(model, link_row, geom)
249
325
  %w[Generalization Aggregation].include?(model.connector_type) &&
250
326
  model.waypoints.size == 2 && stored_ray_style?(model.style, link_row) &&
251
- stored_ray_geometry?(model, geom) && !normalized_port_anchors?(model)
327
+ stored_ray_geometry?(model, geom)
328
+ end
329
+
330
+ # Boundary-port rounding can move a computed jog off a level saved
331
+ # ray. Equal stored origin rows specify the line before clipping.
332
+ def normalized_level_ray?(model, source, target, geom)
333
+ style = model.style.to_h
334
+ return false unless style[:mode] == "3" && style[:lwidth] == "2" &&
335
+ horizontal_ray_gap?(source, target)
336
+
337
+ origins = [stored_ray_origin(source, geom[:sx], geom[:sy]),
338
+ stored_ray_origin(target, geom[:ex], geom[:ey])]
339
+ boxes = [source, target].map { |box| grown_bounds(box, 1) }
340
+ origins.first.y == origins.last.y &&
341
+ origins.zip(boxes).all? { |origin, box| inside_box?(origin, box) }
252
342
  end
253
343
 
254
344
  def stored_ray_style?(style, link_row)
@@ -352,6 +442,14 @@ module Ea
352
442
  route
353
443
  end
354
444
 
445
+ # One stored corner that is level with both boxes already is the
446
+ # route (vertical leg out of the source, horizontal leg into the
447
+ # target); flattening it to the facing edges would lose it.
448
+ def level_corner_route?(points, link_row, connector, src, tgt)
449
+ points.size == 3 && level_entry_style?(link_row, connector) &&
450
+ !level_entry_point(points[1], src).nil? && !level_entry_point(points[1], tgt).nil?
451
+ end
452
+
355
453
  def level_entry_style?(link_row, connector)
356
454
  style = DiagramStyleParser.parse(link_row.style)
357
455
  %w[Generalization Aggregation].include?(connector.connector_type) &&
@@ -419,6 +517,62 @@ module Ea
419
517
  [bounds.y, bounds.y + bounds.height].include?(point[1])
420
518
  end
421
519
 
520
+ # A width-2 Mode 3 Aggregation without a tree style keeps an authored
521
+ # orthogonal stair-step of three or more corners. Each end docks on
522
+ # the 1px outline its adjacent leg points at, from the corner's own
523
+ # coordinate; any end that cannot do so leaves the route unchanged.
524
+ def stored_stair_route(link_row, connector, source, target)
525
+ return nil unless stair_style?(link_row, connector)
526
+
527
+ corners = stored_square_corners(link_row)
528
+ return nil unless corners && corners.size >= 3 && alternating_legs?(corners)
529
+
530
+ first = outline_dock(corners[0], corners[1], source)
531
+ last = outline_dock(corners[-1], corners[-2], target)
532
+ [first, *corners, last] if first && last
533
+ end
534
+
535
+ def stair_style?(link_row, connector)
536
+ style = DiagramStyleParser.parse(link_row.style)
537
+ connector.connector_type == "Aggregation" && style[:mode] == "3" &&
538
+ style[:lwidth] == "2" && !style.key?(:tree)
539
+ end
540
+
541
+ # Every leg is non-zero and runs along the opposite axis of the last.
542
+ def alternating_legs?(corners)
543
+ axes = corners.each_cons(2).map { |from, to| leg_axis(from, to) }
544
+ axes.none?(&:nil?) && axes.each_cons(2).all? { |a, b| a != b }
545
+ end
546
+
547
+ def leg_axis(from, to)
548
+ return :vertical if from[0] == to[0] && from[1] != to[1]
549
+
550
+ :horizontal if from[1] == to[1] && from[0] != to[0]
551
+ end
552
+
553
+ # The dock runs perpendicular to the corner's own leg.
554
+ def outline_dock(corner, neighbour, box)
555
+ if corner[1] == neighbour[1]
556
+ vertical_outline_dock(corner, box)
557
+ else
558
+ horizontal_outline_dock(corner, box)
559
+ end
560
+ end
561
+
562
+ def vertical_outline_dock(corner, box)
563
+ x, y = corner
564
+ return nil unless x.between?(box.x, box.x + box.width) && !within_height?(y, box)
565
+
566
+ [x, y < box.y ? box.y - 1 : box.y + box.height + 1]
567
+ end
568
+
569
+ def horizontal_outline_dock(corner, box)
570
+ x, y = corner
571
+ return nil unless within_height?(y, box) && !x.between?(box.x, box.x + box.width)
572
+
573
+ [x < box.x ? box.x - 1 : box.x + box.width + 1, y]
574
+ end
575
+
422
576
  # A valid lateral corner joins one vertical leg and one horizontal
423
577
  # leg. Keep it even when the boxes also admit a shorter direct route.
424
578
  # ported: both ends carry explicit SX/SY and EX/EY offsets. An
@@ -433,6 +587,66 @@ module Ea
433
587
  corner && lateral_corner_route(style, corner, source, target)
434
588
  end
435
589
 
590
+ # A horizontal lateral tree with no usable stored corner leaves
591
+ # the source's top or bottom edge at its center x, turns at the
592
+ # target's center y and enters the target's facing side edge.
593
+ # When that y lies inside the source's height the route is one
594
+ # horizontal line between the facing side edges. Every dock is
595
+ # flush on its box edge.
596
+ def derived_lateral_route(link_row, connector, source, target)
597
+ style = DiagramStyleParser.parse(link_row.style)
598
+ return nil unless style[:mode] == "3" && style[:tree] == "LH" &&
599
+ derived_lateral_path?(connector.connector_type, link_row.path.to_s)
600
+
601
+ x = source.x + source.width / 2
602
+ y = target.y + target.height / 2
603
+ edge = lateral_target_edge(x, target)
604
+ return nil unless edge
605
+
606
+ if within_height?(y, source)
607
+ side = target.x > x ? source.x + source.width : source.x
608
+ [[side, y], [edge, y]] if ((edge - side) * (edge - x)).positive?
609
+ else
610
+ [[x, y < source.y ? source.y : source.y + source.height], [x, y], [edge, y]]
611
+ end
612
+ end
613
+
614
+ # An Association may carry one stored corner that no valid lateral
615
+ # route accepted; a Generalization derives only from an empty Path.
616
+ def derived_lateral_path?(connector_type, path)
617
+ case connector_type
618
+ when "Association" then path.match?(/\A(?:-?\d+:-?\d+;?)?\z/)
619
+ when "Generalization" then path.empty?
620
+ else false
621
+ end
622
+ end
623
+
624
+ # A derived level line docks flush at its own two ends; anchoring
625
+ # the ray there keeps the emitter's clip from moving them.
626
+ def pin_derived_lateral_line(model, link_row, diagram_row, context)
627
+ style = model.style.to_h
628
+ return model if context || model.waypoints.size != 2 ||
629
+ style[:mode] != "3" || style[:tree] != "LH"
630
+
631
+ source, target = stored_ray_boxes(link_row, diagram_row)
632
+ connector = database.find_connector(link_row.connectorid)
633
+ line = connector && derived_lateral_route(link_row, connector, source, target)
634
+ return model unless line && line.size == 2 &&
635
+ line == model.waypoints.map { |wp| [wp.position.x, wp.position.y] }
636
+
637
+ model.source_anchor, model.target_anchor = line.map { |x, y| Ea::Model::Point.new(x: x, y: y) }
638
+ model.style = style.merge(derived_lateral_line: true)
639
+ model
640
+ end
641
+
642
+ # The target side edge a vertical leg at x faces, or nil when x
643
+ # lies within the target's span.
644
+ def lateral_target_edge(x_position, target)
645
+ return target.x if x_position < target.x
646
+
647
+ target.x + target.width if x_position > target.x + target.width
648
+ end
649
+
436
650
  # An untyped Mode 3 Association corner takes whichever leg
437
651
  # orientation its position fits; width-2 docks sit on the 1px
438
652
  # outer outline.
@@ -613,12 +827,44 @@ module Ea
613
827
  model
614
828
  end
615
829
 
830
+ # A two-waypoint Mode 3 Aggregation with only a target port draws the
831
+ # line from the source center to the target's stored ray origin
832
+ # (center + EX, center - EY), clipped at the outlines; the boundary
833
+ # port is only the derived crossing of that origin's ray.
834
+ def correct_partial_port_origins(model, link_row, diagram_row, geom, context)
835
+ return model if context || !partial_port_origin_candidate?(model, link_row)
836
+
837
+ source, target = stored_ray_boxes(link_row, diagram_row)
838
+ return model unless horizontal_ray_gap?(source, target)
839
+
840
+ model.source_anchor = stored_ray_origin(source, 0, 0)
841
+ model.target_anchor = stored_ray_origin(target, geom[:ex], geom[:ey])
842
+ model
843
+ end
844
+
845
+ def partial_port_origin_candidate?(model, link_row)
846
+ style = model.style.to_h
847
+ model.connector_type == "Aggregation" && model.waypoints.size == 2 &&
848
+ link_row.path.to_s.empty? && style[:mode] == "3" && style[:lwidth] == "2" &&
849
+ !style.key?(:tree) &&
850
+ model.source_port.nil? && model.target_port
851
+ end
852
+
616
853
  def partial_port_jog_candidate?(model, link_row)
617
854
  style = model.style.to_h
618
855
  model.connector_type == "Aggregation" &&
619
- link_row.path.to_s.empty? && style[:mode] == "3" && style[:lwidth] == "2" &&
620
- !style.key?(:tree) && model.source_port.nil? && model.target_port &&
621
- model.waypoints.size == 2
856
+ link_row.path.to_s.empty? && style[:mode] == "3" &&
857
+ !style.key?(:tree) && model.waypoints.size == 2 &&
858
+ explicit_port_side?(model, style[:lwidth])
859
+ end
860
+
861
+ # Width 2 keeps the evidenced target-only port; width 0 accepts a
862
+ # single explicit port at either end.
863
+ def explicit_port_side?(model, width)
864
+ ports = [model.source_port, model.target_port]
865
+ return model.source_port.nil? && !model.target_port.nil? if width == "2"
866
+
867
+ width == "0" && ports.compact.size == 1
622
868
  end
623
869
 
624
870
  def computed_jog_candidate?(model, link_row, geom)
@@ -362,12 +362,20 @@ module Ea
362
362
  target_stereotype: conn.deststereotype,
363
363
  waypoints: waypoints,
364
364
  label_boxes: DEFAULT_LABEL_BOXES,
365
- style: PHANTOM_DOUBLE_BACK_TYPES.include?(conn.connector_type) ? { direct: true } : {},
365
+ style: phantom_style(conn.connector_type),
366
366
  hidden: false,
367
367
  has_geometry_offsets: false
368
368
  )
369
369
  end
370
370
 
371
+ # Internal producer flag, like :direct. A model relationship with no
372
+ # link row is stroked at the 2px box outline, so its ends dock there.
373
+ def phantom_style(connector_type)
374
+ return { direct: true } if PHANTOM_DOUBLE_BACK_TYPES.include?(connector_type)
375
+
376
+ %w[Aggregation Association].include?(connector_type) ? { phantom_outline: true } : {}
377
+ end
378
+
371
379
  # Computes the edge attachment point on source bounds that
372
380
  # faces the target bounds. Uses the center-to-center direction
373
381
  # to pick the nearest edge intersection.
@@ -422,11 +430,14 @@ module Ea
422
430
  is_substitutable: substitutable_connector?(connector)
423
431
  ), link_row, diagram_row, geom)
424
432
  model = collapse_partial_port_jog(model, link_row, diagram_row)
433
+ model = correct_partial_port_origins(model, link_row, diagram_row, geom, context)
425
434
  model = direct_empty_path_ray(model, link_row, diagram_row, geom, context)
435
+ model = pin_derived_lateral_line(model, link_row, diagram_row, context)
426
436
  model = mode2_overlap_ray(model, link_row, diagram_row, geom, context)
427
437
  model = association_stored_ray(model, link_row, diagram_row, geom, context)
428
438
  model = correct_stored_ray_origins(model, link_row, diagram_row, geom, context)
429
- overlap_ray_origins(model, link_row, diagram_row, geom, context)
439
+ model = overlap_ray_origins(model, link_row, diagram_row, geom, context)
440
+ mark_default_square_ray(model, link_row, geom, context)
430
441
  end
431
442
 
432
443
  # Ray origin for one connector end: the element's center
@@ -666,15 +677,17 @@ module Ea
666
677
  src = bounds_from_rect(source_placement)
667
678
  tgt = bounds_from_rect(target_placement)
668
679
  tree_route = stored_square_route(link_row, connector, src, tgt) ||
680
+ stored_stair_route(link_row, connector, src, tgt) ||
669
681
  stored_lateral_route(link_row, connector, src, tgt,
670
682
  ported: source_port && target_port) ||
683
+ derived_lateral_route(link_row, connector, src, tgt) ||
671
684
  horizontal_tree_route(points, link_row, connector, src, tgt)
672
685
  if tree_route
673
686
  points = tree_route
674
687
  elsif %w[Association Aggregation].include?(connector.connector_type) &&
675
688
  edge_out != 2
676
689
  stored_tree = preserve_stored_tree?(points, link_row, connector, src, tgt)
677
- unless stored_tree
690
+ unless stored_tree || level_corner_route?(points, link_row, connector, src, tgt)
678
691
  points = facing_edge_route(points, src, tgt)
679
692
  end
680
693
  # A width-2 Aggregation that keeps its stored bends is
@@ -39,10 +39,16 @@ module Ea
39
39
  return target && [points[0], points[1], target]
40
40
  end
41
41
 
42
+ return nil if flush_lateral_line?(connector, points, source_bounds, target_bounds)
43
+
42
44
  anchors = [connector.source_anchor, connector.target_anchor]
43
45
  bounds = [source_bounds, target_bounds]
44
46
  original = clipped_pairs(bounds, anchors)
45
- return original unless stroke_outline?(connector, bounds) || overlap_ray?(connector)
47
+ unless stroke_outline?(connector, bounds) || overlap_ray?(connector) ||
48
+ bordered_outline?(connector, diagram, [source_ref, target_ref], bounds)
49
+ pairs = default_square_pairs(connector, points, source_bounds, target_bounds, original)
50
+ return stored_top_pairs(connector, source_bounds, pairs)
51
+ end
46
52
 
47
53
  # EA docks these markers on the outer edge of the 2px box stroke.
48
54
  outline = bounds.map do |box|
@@ -50,8 +56,70 @@ module Ea
50
56
  width: box.width + 2, height: box.height + 2)
51
57
  end
52
58
  candidate = clipped_pairs(outline, anchors)
53
- forward_route?(original, candidate) ? candidate : original
59
+ expanded = forward_route?(original, candidate)
60
+ pairs = expanded ? candidate : original
61
+ target = expanded ? outline.last : target_bounds
62
+ pairs = default_square_pairs(connector, points, source_bounds, target, pairs)
63
+ stored_top_pairs(connector, expanded ? outline.first : source_bounds, pairs)
64
+ end
65
+
66
+ # EDGE=1 explicitly seats an external source ray on the top edge;
67
+ # its first contact can instead be the bottom when its origin is below.
68
+ def stored_top_pairs(connector, source, pairs)
69
+ return pairs unless connector.connector_type == "Association" &&
70
+ connector.style.to_h[:stored_top_dock] == true
71
+
72
+ first = connector.source_anchor
73
+ last = connector.target_anchor
74
+ return pairs unless first && last && first.y != last.y
75
+
76
+ ratio = (source.y - first.y).to_f / (last.y - first.y)
77
+ x_position = first.x + ratio * (last.x - first.x)
78
+ return pairs unless ratio.between?(0, 1) &&
79
+ x_position.between?(source.x, source.x + source.width)
80
+
81
+ proposed = [[x_position.floor, source.y], pairs.last]
82
+ forward_route?(pairs, proposed) ? proposed : pairs
83
+ end
84
+ private_class_method :stored_top_pairs
85
+
86
+ # A square ray with an omitted source offset keeps its chosen side
87
+ # edge and projects that end to the clipped target's horizontal row.
88
+ def default_square_pairs(connector, points, source, target, pairs)
89
+ return pairs unless connector.style.to_h[:default_square_ray] == true
90
+ return pairs unless points.first[1] == points.last[1] &&
91
+ on_side_edge?(points.first, source) && on_side_edge?(pairs.last, target)
92
+
93
+ first = [points.first[0], pairs.last[1]]
94
+ return pairs unless on_side_edge?(first, source)
95
+
96
+ proposed = [first, pairs.last]
97
+ forward_route?(points, proposed) ? proposed : pairs
98
+ end
99
+ private_class_method :default_square_pairs
100
+
101
+ # A horizontal lateral tree the QEA builder derived as one level
102
+ # line (internal style flag) keeps it between the facing side edges.
103
+ def flush_lateral_line?(connector, points, source, target)
104
+ style = connector.style.to_h
105
+ return false unless style[:derived_lateral_line] && style[:mode] == "3" &&
106
+ style[:tree] == "LH" && points.size == 2 &&
107
+ %w[Association Generalization].include?(connector.connector_type)
108
+ return false unless connector.source_anchor && connector.target_anchor
109
+
110
+ first, last = points
111
+ pinned = [[connector.source_anchor.x, connector.source_anchor.y],
112
+ [connector.target_anchor.x, connector.target_anchor.y]]
113
+ pinned == [first, last] && first[1] == last[1] &&
114
+ on_side_edge?(first, source) && on_side_edge?(last, target)
54
115
  end
116
+ private_class_method :flush_lateral_line?
117
+
118
+ def on_side_edge?(point, bounds)
119
+ [bounds.x, bounds.x + bounds.width].include?(point[0]) &&
120
+ point[1].between?(bounds.y, bounds.y + bounds.height)
121
+ end
122
+ private_class_method :on_side_edge?
55
123
 
56
124
  def lateral_tree?(connector, points)
57
125
  style = connector.style.to_h
@@ -97,7 +165,7 @@ module Ea
97
165
  private_class_method :facing_coordinate
98
166
 
99
167
  def stroke_outline?(connector, bounds)
100
- connector.style.to_h[:lwidth] == "2" &&
168
+ (connector.style.to_h[:lwidth] == "2" || default_model_connector?(connector)) &&
101
169
  bounds.all? { |box| box.width.positive? && box.height.positive? } &&
102
170
  outline_connector?(connector, bounds)
103
171
  end
@@ -111,9 +179,42 @@ module Ea
111
179
  end
112
180
  private_class_method :overlap_ray?
113
181
 
182
+ # The QEA builder flags a model relationship drawn without any
183
+ # diagram link row; EA strokes it at the 2px box outline.
184
+ def default_model_connector?(connector)
185
+ connector.style.to_h[:phantom_outline] == true && !connector.has_geometry_offsets &&
186
+ %w[Aggregation Association].include?(connector.connector_type)
187
+ end
188
+ private_class_method :default_model_connector?
189
+
190
+ # A thin straight Generalization/Aggregation/Association between two elements
191
+ # drawn with a 2px border stops on the outer edge of those borders.
192
+ def bordered_outline?(connector, diagram, refs, bounds)
193
+ style = connector.style.to_h
194
+ return false unless style[:lwidth] == "0" && !style[:tree]
195
+ return false unless %w[Generalization Aggregation Association].include?(connector.connector_type)
196
+ return false unless bounds.all? { |box| box.width.positive? && box.height.positive? }
197
+
198
+ ends = [[refs.first, style[:soid]], [refs.last, style[:eoid]]]
199
+ ends.all? { |ref, duid| border_two?(diagram, ref, duid) }
200
+ end
201
+ private_class_method :bordered_outline?
202
+
203
+ # Every placement of the element (the DUID-matched one when the
204
+ # connector names it) must declare a border width of 2.
205
+ def border_two?(diagram, ref, duid)
206
+ placed = Array(diagram&.elements).select { |el| el.model_element_ref == ref }
207
+ named = placed.select { |el| duid && el.duid.to_s.casecmp?(duid.to_s) }
208
+ placed = named unless named.empty?
209
+ placed.any? && placed.all? { |el| el.line_width == 2 || el.style.to_h[:lwth] == "2" }
210
+ end
211
+ private_class_method :border_two?
212
+
114
213
  def outline_connector?(connector, bounds)
214
+ return true if default_model_connector?(connector)
115
215
  return true if %w[Generalization Aggregation].include?(connector.connector_type)
116
216
 
217
+ return true if association_ray_outline?(connector)
117
218
  return false unless stored_association?(connector) || offset_free_association?(connector)
118
219
 
119
220
  # Diagonal Association rounding and tree docks keep their existing rules.
@@ -122,6 +223,15 @@ module Ea
122
223
  end
123
224
  private_class_method :outline_connector?
124
225
 
226
+ # The QEA builder marks the stored Association rays it produced;
227
+ # their diagonal docks sit on the outer stroke edge like the
228
+ # axis-aligned ones. Unmarked diagonals keep the legacy docking.
229
+ def association_ray_outline?(connector)
230
+ connector.connector_type == "Association" &&
231
+ connector.style.to_h[:association_ray_outline] == true
232
+ end
233
+ private_class_method :association_ray_outline?
234
+
125
235
  def stored_association?(connector)
126
236
  style = connector.style.to_h
127
237
  connector.connector_type == "Association" && connector.has_geometry_offsets &&
@@ -244,12 +354,20 @@ module Ea
244
354
  # anchor row is one explicit horizontal line: EA seats neither end
245
355
  # in a compartment row, unlike Generalization/Aggregation targets.
246
356
  def explicit_association_ray?(pairs, connector)
357
+ produced = connector.style.to_h[:stored_association_ray] == true
358
+ return true if produced && pairs.first[1] == pairs.last[1]
359
+
360
+ certified_association_ray?(pairs, connector)
361
+ end
362
+
363
+ def certified_association_ray?(pairs, connector)
247
364
  return false unless explicit_horizontal_ray?(connector, %w[Association])
248
365
 
249
366
  connector.source_anchor.y == connector.target_anchor.y &&
250
367
  connector.source_port.y == connector.source_anchor.y &&
251
368
  pairs.first[1] == pairs.last[1]
252
369
  end
370
+ private_class_method :certified_association_ray?
253
371
  private_class_method :explicit_association_ray?
254
372
 
255
373
  def explicit_horizontal_ray?(connector, types = %w[Generalization Aggregation])
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.100"
7
+ VERSION = "0.6.102"
8
8
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ea
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.100
4
+ version: 0.6.102
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.