ea 0.6.99 → 0.6.101

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: 4b9e7581bab2de7d7c5766a5f141c99885bb59cbb7ad974c313c26e86bfb96a2
4
- data.tar.gz: 8933e36fb2422953eddb1d60069664e39177547beaf2ec17ed17c5b9f3aa4eac
3
+ metadata.gz: 7c722ef7c5b7c7968f5beb08538b31b8090a3ab1533b0418a9600752c2724ef3
4
+ data.tar.gz: 818694e4771383a00ae453f76a7b3f98e79b6b5a839d9459d415ec72129e4226
5
5
  SHA512:
6
- metadata.gz: ab6882700d57207a5fd2c1b27fa0befe5f287bb61f13387e99dc8f4429dbede75320d9fb6124a126094455d38d4dff7a41dbfc258633609c6695c5dcd216b36d
7
- data.tar.gz: 82cd636abfe8c20a03dc8f6c5410192c65e674093eaaabc081e8fc84b7ec6544a87f3c87ff255f90fcaa1bc0f7704fdf1b2e46f344cf9288cd51ec4a7e8cfa97
6
+ metadata.gz: b00b8fddbd7701f724dc20bd6640dfc53b7a99903d7a075581be94c9f3674254a114fa976a2a895d5ba231ca3b0c93f7b8ce241c42ef4d5c9e106f82c7b660a6
7
+ data.tar.gz: c41701279192e06adba33b39063dac26a9355dedf1a46fd687079013c341c880f8f3d83278876593cf27733c527f41e22032140b901246b0b6e335cf29dcb308
@@ -23,6 +23,56 @@ module Ea
23
23
  set_stored_ray_origins(model, source, target, geom)
24
24
  end
25
25
 
26
+ # A Mode 2 Generalization whose stored Path is a horizontal jog
27
+ # between vertically separated boxes that overlap in x is EA's
28
+ # auto-route: one vertical ray at the overlap midpoint plus SX.
29
+ # The stored jog is stale; the ray docks on both 1px outlines.
30
+ def mode2_overlap_ray(model, link_row, diagram_row, geom, context)
31
+ return model if context || !mode2_overlap_candidate?(model, link_row, geom)
32
+
33
+ source, target = stored_ray_boxes(link_row, diagram_row)
34
+ column = overlap_ray_coordinate(source.x, source.width, target.x, target.width, geom[:sx])
35
+ return model unless column && vertical_separation?(source, target)
36
+
37
+ set_overlap_ray(model, column, source, target)
38
+ end
39
+
40
+ def set_overlap_ray(model, column, source, target)
41
+ model.waypoints = overlap_ray_ends(column, source, target).map do |x, y|
42
+ Ea::Model::Waypoint.new(position: Ea::Model::Point.new(x: x, y: y))
43
+ end
44
+ model.source_anchor, model.target_anchor = [source, target].map do |box|
45
+ Ea::Model::Point.new(x: column, y: box.y + box.height / 2)
46
+ end
47
+ model
48
+ end
49
+
50
+ def mode2_overlap_candidate?(model, link_row, geom)
51
+ model.connector_type == "Generalization" && model.has_geometry_offsets &&
52
+ mode2_overlap_style?(model.style.to_h, geom) && model.waypoints.size == 4 &&
53
+ horizontal_jog?(intermediate_waypoints(link_row))
54
+ end
55
+
56
+ def mode2_overlap_style?(style, geom)
57
+ style[:mode] == "2" && style[:lwidth] == "2" && !style[:tree] && geom[:edge] == 1 && geom[:sx]
58
+ end
59
+
60
+ def horizontal_jog?(corners)
61
+ corners.size == 2 && corners[0][1] == corners[1][1]
62
+ end
63
+
64
+ def vertical_separation?(source, target)
65
+ source.y + source.height < target.y || target.y + target.height < source.y
66
+ end
67
+
68
+ def overlap_ray_ends(column, source, target)
69
+ if target.y + target.height < source.y
70
+ [[column, source.y - 1], [column, target.y + target.height + 1]]
71
+ else
72
+ [[column, source.y + source.height + 1], [column, target.y - 1]]
73
+ end
74
+ end
75
+
26
76
  def empty_path_ray_gap?(source, target, geom)
27
77
  return false unless source.x + source.width < target.x
28
78
 
@@ -55,6 +105,149 @@ module Ea
55
105
  set_stored_ray_origins(model, source, target, geom)
56
106
  end
57
107
 
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.
111
+ def association_stored_ray(model, link_row, diagram_row, geom, context)
112
+ return model if context
113
+ return model unless association_ray_candidate?(model, link_row)
114
+
115
+ source, target = stored_ray_boxes(link_row, diagram_row)
116
+ origins = association_ray_origins(source, target, geom, model.style.to_h[:lwidth])
117
+ return model unless origins
118
+
119
+ model.waypoints = port_waypoints(model)
120
+ model.source_anchor, model.target_anchor = origins
121
+ model.style = model.style.to_h.merge(stored_association_ray: true, association_ray_outline: true)
122
+ model
123
+ end
124
+
125
+ def port_waypoints(model)
126
+ [model.source_port, model.target_port].map do |point|
127
+ Ea::Model::Waypoint.new(position: point)
128
+ end
129
+ end
130
+
131
+ # Width-2 boxes also own their 1px outer stroke, so a stored
132
+ # origin on that outline is still inside its box.
133
+ def association_ray_origins(source, target, geom, width)
134
+ outline = width == "2" ? 1 : 0
135
+ origins = [stored_ray_origin(source, geom[:sx], geom[:sy]),
136
+ stored_ray_origin(target, geom[:ex], geom[:ey])]
137
+ boxes = [source, target].map { |box| grown_bounds(box, outline) }
138
+ inside = origins.zip(boxes).all? { |o, b| inside_box?(o, b) }
139
+ return unless inside && origins.first != origins.last
140
+
141
+ origins if width == "2" || sub_pixel_safe_origins?(origins, [source, target] + boxes)
142
+ end
143
+
144
+ def grown_bounds(bounds, amount)
145
+ Ea::Model::Bounds.new(x: bounds.x - amount, y: bounds.y - amount,
146
+ width: bounds.width + 2 * amount,
147
+ height: bounds.height + 2 * amount)
148
+ end
149
+
150
+ def association_ray_candidate?(model, link_row)
151
+ model.connector_type == "Association" && link_row.path.to_s.empty? &&
152
+ model.has_geometry_offsets && model.source_port && model.target_port &&
153
+ association_ray_style?(model.style.to_h)
154
+ end
155
+
156
+ def association_ray_style?(style)
157
+ style[:mode] == "3" && !style[:tree] && %w[0 2].include?(style[:lwidth]) &&
158
+ %i[direct regenerated].none? { |key| style.key?(key) }
159
+ end
160
+
161
+ # Width-0 origins are whole pixels while EA's box centers may sit
162
+ # on a half pixel. An origin on its border then clips differently
163
+ # unless the line is axis-aligned, which no rounding changes.
164
+ def sub_pixel_safe_origins?(origins, boxes)
165
+ first, last = origins
166
+ axis_aligned = first.x == last.x || first.y == last.y
167
+ axis_aligned || origins.none? { |point| boxes.any? { |box| on_border?(point, box) } }
168
+ end
169
+
170
+ def on_border?(point, bounds)
171
+ [bounds.x, bounds.x + bounds.width].include?(point.x) ||
172
+ [bounds.y, bounds.y + bounds.height].include?(point.y)
173
+ end
174
+
175
+ def inside_box?(point, bounds)
176
+ point.x.between?(bounds.x, bounds.x + bounds.width) &&
177
+ point.y.between?(bounds.y, bounds.y + bounds.height)
178
+ end
179
+
180
+ # A Mode 2 Association with equal SX/EX (vertical EDGE 1/3) or
181
+ # equal SY/EY (horizontal EDGE 4) and no custom Path docks as a
182
+ # straight line through the middle of the boxes' overlap, moved
183
+ # by the shared stored offset. Stored SY counts upward.
184
+ def overlap_ray_origins(model, link_row, diagram_row, geom, context)
185
+ return model if context
186
+ return model unless overlap_ray_candidate?(model, link_row, geom)
187
+
188
+ source, target = stored_ray_boxes(link_row, diagram_row)
189
+ origins = overlap_origins(source, target, geom)
190
+ return model unless origins
191
+
192
+ model.source_anchor, model.target_anchor = origins.map do |ox, oy|
193
+ Ea::Model::Point.new(x: ox, y: oy)
194
+ end
195
+ model.style[:overlap_ray] = true
196
+ model
197
+ end
198
+
199
+ # Both ray origins share the overlap coordinate on the axis the
200
+ # offsets move and sit at each box's centre on the other axis.
201
+ def overlap_origins(source, target, geom)
202
+ along, across, offset = overlap_axes(geom)
203
+ coordinate = overlap_ray_coordinate(*box_span(source, along), *box_span(target, along), offset)
204
+ return nil unless coordinate && disjoint_span?(*box_span(source, across), *box_span(target, across))
205
+
206
+ [source, target].map do |box|
207
+ start, size = box_span(box, across)
208
+ centre = start + size / 2.0
209
+ geom[:edge] == 4 ? [centre, coordinate] : [coordinate, centre]
210
+ end
211
+ end
212
+
213
+ # [axis the offsets move, axis the boxes face, stored offset]
214
+ def overlap_axes(geom)
215
+ return [%i[y height], %i[x width], -geom[:sy]] if geom[:edge] == 4
216
+
217
+ [%i[x width], %i[y height], geom[:sx]]
218
+ end
219
+
220
+ def box_span(box, keys)
221
+ keys.map { |key| box.public_send(key) }
222
+ end
223
+
224
+ def overlap_ray_candidate?(model, link_row, geom)
225
+ model.connector_type == "Association" && model.style[:mode] == "2" &&
226
+ model.waypoints.size == 2 && link_row.path.to_s.empty? &&
227
+ model.has_geometry_offsets && uniform_overlap_offsets?(geom)
228
+ end
229
+
230
+ def uniform_overlap_offsets?(geom)
231
+ return false unless geom.values_at(:sx, :sy, :ex, :ey).all?
232
+
233
+ geom[:edge] == 4 ? geom[:sy] == geom[:ey] : [1, 3].include?(geom[:edge]) && geom[:sx] == geom[:ex]
234
+ end
235
+
236
+ # Floor of the overlap midpoint plus the shared offset; nil
237
+ # when the boxes do not overlap on this axis.
238
+ def overlap_ray_coordinate(start_a, size_a, start_b, size_b, offset)
239
+ low = [start_a, start_b].max
240
+ high = [start_a + size_a, start_b + size_b].min
241
+ return nil if low > high
242
+
243
+ coordinate = ((low + high) / 2.0 + offset).floor
244
+ coordinate if coordinate.between?(low, high)
245
+ end
246
+
247
+ def disjoint_span?(start_a, size_a, start_b, size_b)
248
+ start_a + size_a < start_b || start_b + size_b < start_a
249
+ end
250
+
58
251
  def stored_ray_boxes(link_row, diagram_row)
59
252
  connector = database.find_connector(link_row.connectorid)
60
253
  [source_placement_for(connector, diagram_row),
@@ -134,7 +327,7 @@ module Ea
134
327
 
135
328
  case connector.connector_type
136
329
  when "Aggregation"
137
- return if geometry_has_offsets?(link_row.geometry)
330
+ return unless style[:lwidth] == "0" || !geometry_has_offsets?(link_row.geometry)
138
331
 
139
332
  redock_child_below(points, bounds_from_rect(source_placement), bounds_from_rect(target_placement))
140
333
  when "Association"
@@ -174,6 +367,14 @@ module Ea
174
367
  route
175
368
  end
176
369
 
370
+ # One stored corner that is level with both boxes already is the
371
+ # route (vertical leg out of the source, horizontal leg into the
372
+ # target); flattening it to the facing edges would lose it.
373
+ def level_corner_route?(points, link_row, connector, src, tgt)
374
+ points.size == 3 && level_entry_style?(link_row, connector) &&
375
+ !level_entry_point(points[1], src).nil? && !level_entry_point(points[1], tgt).nil?
376
+ end
377
+
177
378
  def level_entry_style?(link_row, connector)
178
379
  style = DiagramStyleParser.parse(link_row.style)
179
380
  %w[Generalization Aggregation].include?(connector.connector_type) &&
@@ -241,6 +442,62 @@ module Ea
241
442
  [bounds.y, bounds.y + bounds.height].include?(point[1])
242
443
  end
243
444
 
445
+ # A width-2 Mode 3 Aggregation without a tree style keeps an authored
446
+ # orthogonal stair-step of three or more corners. Each end docks on
447
+ # the 1px outline its adjacent leg points at, from the corner's own
448
+ # coordinate; any end that cannot do so leaves the route unchanged.
449
+ def stored_stair_route(link_row, connector, source, target)
450
+ return nil unless stair_style?(link_row, connector)
451
+
452
+ corners = stored_square_corners(link_row)
453
+ return nil unless corners && corners.size >= 3 && alternating_legs?(corners)
454
+
455
+ first = outline_dock(corners[0], corners[1], source)
456
+ last = outline_dock(corners[-1], corners[-2], target)
457
+ [first, *corners, last] if first && last
458
+ end
459
+
460
+ def stair_style?(link_row, connector)
461
+ style = DiagramStyleParser.parse(link_row.style)
462
+ connector.connector_type == "Aggregation" && style[:mode] == "3" &&
463
+ style[:lwidth] == "2" && !style.key?(:tree)
464
+ end
465
+
466
+ # Every leg is non-zero and runs along the opposite axis of the last.
467
+ def alternating_legs?(corners)
468
+ axes = corners.each_cons(2).map { |from, to| leg_axis(from, to) }
469
+ axes.none?(&:nil?) && axes.each_cons(2).all? { |a, b| a != b }
470
+ end
471
+
472
+ def leg_axis(from, to)
473
+ return :vertical if from[0] == to[0] && from[1] != to[1]
474
+
475
+ :horizontal if from[1] == to[1] && from[0] != to[0]
476
+ end
477
+
478
+ # The dock runs perpendicular to the corner's own leg.
479
+ def outline_dock(corner, neighbour, box)
480
+ if corner[1] == neighbour[1]
481
+ vertical_outline_dock(corner, box)
482
+ else
483
+ horizontal_outline_dock(corner, box)
484
+ end
485
+ end
486
+
487
+ def vertical_outline_dock(corner, box)
488
+ x, y = corner
489
+ return nil unless x.between?(box.x, box.x + box.width) && !within_height?(y, box)
490
+
491
+ [x, y < box.y ? box.y - 1 : box.y + box.height + 1]
492
+ end
493
+
494
+ def horizontal_outline_dock(corner, box)
495
+ x, y = corner
496
+ return nil unless within_height?(y, box) && !x.between?(box.x, box.x + box.width)
497
+
498
+ [x < box.x ? box.x - 1 : box.x + box.width + 1, y]
499
+ end
500
+
244
501
  # A valid lateral corner joins one vertical leg and one horizontal
245
502
  # leg. Keep it even when the boxes also admit a shorter direct route.
246
503
  # ported: both ends carry explicit SX/SY and EX/EY offsets. An
@@ -255,6 +512,66 @@ module Ea
255
512
  corner && lateral_corner_route(style, corner, source, target)
256
513
  end
257
514
 
515
+ # A horizontal lateral tree with no usable stored corner leaves
516
+ # the source's top or bottom edge at its center x, turns at the
517
+ # target's center y and enters the target's facing side edge.
518
+ # When that y lies inside the source's height the route is one
519
+ # horizontal line between the facing side edges. Every dock is
520
+ # flush on its box edge.
521
+ def derived_lateral_route(link_row, connector, source, target)
522
+ style = DiagramStyleParser.parse(link_row.style)
523
+ return nil unless style[:mode] == "3" && style[:tree] == "LH" &&
524
+ derived_lateral_path?(connector.connector_type, link_row.path.to_s)
525
+
526
+ x = source.x + source.width / 2
527
+ y = target.y + target.height / 2
528
+ edge = lateral_target_edge(x, target)
529
+ return nil unless edge
530
+
531
+ if within_height?(y, source)
532
+ side = target.x > x ? source.x + source.width : source.x
533
+ [[side, y], [edge, y]] if ((edge - side) * (edge - x)).positive?
534
+ else
535
+ [[x, y < source.y ? source.y : source.y + source.height], [x, y], [edge, y]]
536
+ end
537
+ end
538
+
539
+ # An Association may carry one stored corner that no valid lateral
540
+ # route accepted; a Generalization derives only from an empty Path.
541
+ def derived_lateral_path?(connector_type, path)
542
+ case connector_type
543
+ when "Association" then path.match?(/\A(?:-?\d+:-?\d+;?)?\z/)
544
+ when "Generalization" then path.empty?
545
+ else false
546
+ end
547
+ end
548
+
549
+ # A derived level line docks flush at its own two ends; anchoring
550
+ # the ray there keeps the emitter's clip from moving them.
551
+ def pin_derived_lateral_line(model, link_row, diagram_row, context)
552
+ style = model.style.to_h
553
+ return model if context || model.waypoints.size != 2 ||
554
+ style[:mode] != "3" || style[:tree] != "LH"
555
+
556
+ source, target = stored_ray_boxes(link_row, diagram_row)
557
+ connector = database.find_connector(link_row.connectorid)
558
+ line = connector && derived_lateral_route(link_row, connector, source, target)
559
+ return model unless line && line.size == 2 &&
560
+ line == model.waypoints.map { |wp| [wp.position.x, wp.position.y] }
561
+
562
+ model.source_anchor, model.target_anchor = line.map { |x, y| Ea::Model::Point.new(x: x, y: y) }
563
+ model.style = style.merge(derived_lateral_line: true)
564
+ model
565
+ end
566
+
567
+ # The target side edge a vertical leg at x faces, or nil when x
568
+ # lies within the target's span.
569
+ def lateral_target_edge(x_position, target)
570
+ return target.x if x_position < target.x
571
+
572
+ target.x + target.width if x_position > target.x + target.width
573
+ end
574
+
258
575
  # An untyped Mode 3 Association corner takes whichever leg
259
576
  # orientation its position fits; width-2 docks sit on the 1px
260
577
  # outer outline.
@@ -334,14 +651,13 @@ module Ea
334
651
  # Keep a vertical tree trunk whose bends still fit both boxes
335
652
  # and the open gap; the closest facing edges need not share its x.
336
653
  def preserve_stored_tree?(points, link_row, connector, src, tgt)
337
- return false unless connector.connector_type == "Aggregation" && points.size == 4
654
+ return false unless points.size == 4 && stored_tree_type?(points, link_row, connector)
338
655
 
339
656
  style = DiagramStyleParser.parse(link_row.style)
340
657
  return false unless style[:lwidth] == "2" || flush_width_zero_tree?(link_row)
341
- return false unless style[:tree] == "V" ||
342
- (style[:mode] == "3" && style[:tree].nil?)
658
+ return false unless vertical_tree_style?(style)
343
659
 
344
- tree_bends_fit?(points[1], points[-2], src, tgt)
660
+ tree_bends_fit?(points[1], points[-2], src, tgt, flush_width_zero_tree?(link_row))
345
661
  end
346
662
 
347
663
  # A width-2 Mode 3 horizontal tree keeps its stored vertical trunk
@@ -412,6 +728,70 @@ module Ea
412
728
  model
413
729
  end
414
730
 
731
+ # Same one-pixel collapse for a two-waypoint computed line when only
732
+ # the target carries an explicit port (the source end is the default
733
+ # edge midpoint): the left-hand end's y wins.
734
+ def collapse_partial_port_jog(model, link_row, diagram_row)
735
+ return model unless partial_port_jog_candidate?(model, link_row)
736
+
737
+ connection = database.find_connector(link_row.connectorid)
738
+ src, tgt = [connection.start_object_id, connection.end_object_id].map do |id|
739
+ placement = diagram_object_placement(diagram_row.diagram_id, id)
740
+ placement && bounds_from_rect(placement)
741
+ end
742
+ from, to = model.waypoints.map(&:position)
743
+ return model unless src && tgt && facing_jog_ports?(from, to, src, tgt)
744
+
745
+ level = [from, to].min_by(&:x).y
746
+ ends = [[from.x, level], [to.x, level]].map do |x, y|
747
+ Ea::Model::Point.new(x: x, y: y)
748
+ end
749
+ model.waypoints = ends.map { |point| Ea::Model::Waypoint.new(position: point) }
750
+ model.source_anchor = model.source_port = ends.first
751
+ model.target_anchor = model.target_port = ends.last
752
+ model
753
+ end
754
+
755
+ # A two-waypoint Mode 3 Aggregation with only a target port draws the
756
+ # line from the source center to the target's stored ray origin
757
+ # (center + EX, center - EY), clipped at the outlines; the boundary
758
+ # port is only the derived crossing of that origin's ray.
759
+ def correct_partial_port_origins(model, link_row, diagram_row, geom, context)
760
+ return model if context || !partial_port_origin_candidate?(model, link_row)
761
+
762
+ source, target = stored_ray_boxes(link_row, diagram_row)
763
+ return model unless horizontal_ray_gap?(source, target)
764
+
765
+ model.source_anchor = stored_ray_origin(source, 0, 0)
766
+ model.target_anchor = stored_ray_origin(target, geom[:ex], geom[:ey])
767
+ model
768
+ end
769
+
770
+ def partial_port_origin_candidate?(model, link_row)
771
+ style = model.style.to_h
772
+ model.connector_type == "Aggregation" && model.waypoints.size == 2 &&
773
+ link_row.path.to_s.empty? && style[:mode] == "3" && style[:lwidth] == "2" &&
774
+ !style.key?(:tree) &&
775
+ model.source_port.nil? && model.target_port
776
+ end
777
+
778
+ def partial_port_jog_candidate?(model, link_row)
779
+ style = model.style.to_h
780
+ model.connector_type == "Aggregation" &&
781
+ link_row.path.to_s.empty? && style[:mode] == "3" &&
782
+ !style.key?(:tree) && model.waypoints.size == 2 &&
783
+ explicit_port_side?(model, style[:lwidth])
784
+ end
785
+
786
+ # Width 2 keeps the evidenced target-only port; width 0 accepts a
787
+ # single explicit port at either end.
788
+ def explicit_port_side?(model, width)
789
+ ports = [model.source_port, model.target_port]
790
+ return model.source_port.nil? && !model.target_port.nil? if width == "2"
791
+
792
+ width == "0" && ports.compact.size == 1
793
+ end
794
+
415
795
  def computed_jog_candidate?(model, link_row, geom)
416
796
  style = model.style.to_h
417
797
  model.connector_type == "Generalization" && geom[:edge] == 2 &&
@@ -438,14 +818,32 @@ module Ea
438
818
  style[:mode] == "3" && style[:tree] == "V" && style[:lwidth] == "0"
439
819
  end
440
820
 
441
- def tree_bends_fit?(first, last, source, target)
442
- first[0] != last[0] && first[1] == last[1] &&
443
- within_vertical_gap?(first[1], source, target) &&
821
+ def vertical_tree_style?(style)
822
+ style[:tree] == "V" || (style[:mode] == "3" && style[:tree].nil?)
823
+ end
824
+
825
+ def stored_tree_type?(points, link_row, connector)
826
+ connector.connector_type == "Aggregation" || flush_straight_association?(points, link_row, connector)
827
+ end
828
+
829
+ # A flush Association keeps only a straight trunk (one bend stored twice).
830
+ def flush_straight_association?(points, link_row, connector)
831
+ connector.connector_type == "Association" && points[1] == points[-2] &&
832
+ flush_width_zero_tree?(link_row)
833
+ end
834
+
835
+ # A flush trunk may also hang below its parent; a bend repeated at
836
+ # one x is a straight trunk with a single bend.
837
+ def tree_bends_fit?(first, last, source, target, flush)
838
+ first[1] == last[1] &&
839
+ within_vertical_gap?(first[1], source, target, flush) &&
444
840
  within_horizontal_span?(first, source) && within_horizontal_span?(last, target)
445
841
  end
446
842
 
447
- def within_vertical_gap?(height, source, target)
448
- height < source.y - 1 && height > target.y + target.height + 1
843
+ def within_vertical_gap?(height, source, target, flush)
844
+ return true if height < source.y - 1 && height > target.y + target.height + 1
845
+
846
+ flush && height > source.y + source.height + 1 && height < target.y - 1
449
847
  end
450
848
 
451
849
  def within_horizontal_span?(point, bounds)
@@ -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.
@@ -421,8 +429,14 @@ module Ea
421
429
  has_geometry_offsets: geometry_has_offsets?(link_row.geometry),
422
430
  is_substitutable: substitutable_connector?(connector)
423
431
  ), link_row, diagram_row, geom)
432
+ model = collapse_partial_port_jog(model, link_row, diagram_row)
433
+ model = correct_partial_port_origins(model, link_row, diagram_row, geom, context)
424
434
  model = direct_empty_path_ray(model, link_row, diagram_row, geom, context)
425
- correct_stored_ray_origins(model, link_row, diagram_row, geom, context)
435
+ model = pin_derived_lateral_line(model, link_row, diagram_row, context)
436
+ model = mode2_overlap_ray(model, link_row, diagram_row, geom, context)
437
+ model = association_stored_ray(model, link_row, diagram_row, geom, context)
438
+ model = correct_stored_ray_origins(model, link_row, diagram_row, geom, context)
439
+ overlap_ray_origins(model, link_row, diagram_row, geom, context)
426
440
  end
427
441
 
428
442
  # Ray origin for one connector end: the element's center
@@ -662,15 +676,17 @@ module Ea
662
676
  src = bounds_from_rect(source_placement)
663
677
  tgt = bounds_from_rect(target_placement)
664
678
  tree_route = stored_square_route(link_row, connector, src, tgt) ||
679
+ stored_stair_route(link_row, connector, src, tgt) ||
665
680
  stored_lateral_route(link_row, connector, src, tgt,
666
681
  ported: source_port && target_port) ||
682
+ derived_lateral_route(link_row, connector, src, tgt) ||
667
683
  horizontal_tree_route(points, link_row, connector, src, tgt)
668
684
  if tree_route
669
685
  points = tree_route
670
686
  elsif %w[Association Aggregation].include?(connector.connector_type) &&
671
687
  edge_out != 2
672
688
  stored_tree = preserve_stored_tree?(points, link_row, connector, src, tgt)
673
- unless stored_tree
689
+ unless stored_tree || level_corner_route?(points, link_row, connector, src, tgt)
674
690
  points = facing_edge_route(points, src, tgt)
675
691
  end
676
692
  # A width-2 Aggregation that keeps its stored bends is
@@ -39,10 +39,15 @@ 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)
47
+ unless stroke_outline?(connector, bounds) || overlap_ray?(connector) ||
48
+ bordered_outline?(connector, diagram, [source_ref, target_ref], bounds)
49
+ return original
50
+ end
46
51
 
47
52
  # EA docks these markers on the outer edge of the 2px box stroke.
48
53
  outline = bounds.map do |box|
@@ -53,6 +58,29 @@ module Ea
53
58
  forward_route?(original, candidate) ? candidate : original
54
59
  end
55
60
 
61
+ # A horizontal lateral tree the QEA builder derived as one level
62
+ # line (internal style flag) keeps it between the facing side edges.
63
+ def flush_lateral_line?(connector, points, source, target)
64
+ style = connector.style.to_h
65
+ return false unless style[:derived_lateral_line] && style[:mode] == "3" &&
66
+ style[:tree] == "LH" && points.size == 2 &&
67
+ %w[Association Generalization].include?(connector.connector_type)
68
+ return false unless connector.source_anchor && connector.target_anchor
69
+
70
+ first, last = points
71
+ pinned = [[connector.source_anchor.x, connector.source_anchor.y],
72
+ [connector.target_anchor.x, connector.target_anchor.y]]
73
+ pinned == [first, last] && first[1] == last[1] &&
74
+ on_side_edge?(first, source) && on_side_edge?(last, target)
75
+ end
76
+ private_class_method :flush_lateral_line?
77
+
78
+ def on_side_edge?(point, bounds)
79
+ [bounds.x, bounds.x + bounds.width].include?(point[0]) &&
80
+ point[1].between?(bounds.y, bounds.y + bounds.height)
81
+ end
82
+ private_class_method :on_side_edge?
83
+
56
84
  def lateral_tree?(connector, points)
57
85
  style = connector.style.to_h
58
86
  points.size == 3 && style[:mode] == "3" && style[:lwidth] == "2" &&
@@ -97,15 +125,56 @@ module Ea
97
125
  private_class_method :facing_coordinate
98
126
 
99
127
  def stroke_outline?(connector, bounds)
100
- connector.style.to_h[:lwidth] == "2" &&
128
+ (connector.style.to_h[:lwidth] == "2" || default_model_connector?(connector)) &&
101
129
  bounds.all? { |box| box.width.positive? && box.height.positive? } &&
102
130
  outline_connector?(connector, bounds)
103
131
  end
104
132
  private_class_method :stroke_outline?
105
133
 
134
+ # The QEA builder marks the Mode 2 Association overlap rays it
135
+ # produced; EA docks them on the outer stroke edge whatever the
136
+ # line width. Naturally aligned centres keep the legacy docking.
137
+ def overlap_ray?(connector)
138
+ connector.connector_type == "Association" && connector.style.to_h[:overlap_ray] == true
139
+ end
140
+ private_class_method :overlap_ray?
141
+
142
+ # The QEA builder flags a model relationship drawn without any
143
+ # diagram link row; EA strokes it at the 2px box outline.
144
+ def default_model_connector?(connector)
145
+ connector.style.to_h[:phantom_outline] == true && !connector.has_geometry_offsets &&
146
+ %w[Aggregation Association].include?(connector.connector_type)
147
+ end
148
+ private_class_method :default_model_connector?
149
+
150
+ # A thin straight Generalization/Aggregation/Association between two elements
151
+ # drawn with a 2px border stops on the outer edge of those borders.
152
+ def bordered_outline?(connector, diagram, refs, bounds)
153
+ style = connector.style.to_h
154
+ return false unless style[:lwidth] == "0" && !style[:tree]
155
+ return false unless %w[Generalization Aggregation Association].include?(connector.connector_type)
156
+ return false unless bounds.all? { |box| box.width.positive? && box.height.positive? }
157
+
158
+ ends = [[refs.first, style[:soid]], [refs.last, style[:eoid]]]
159
+ ends.all? { |ref, duid| border_two?(diagram, ref, duid) }
160
+ end
161
+ private_class_method :bordered_outline?
162
+
163
+ # Every placement of the element (the DUID-matched one when the
164
+ # connector names it) must declare a border width of 2.
165
+ def border_two?(diagram, ref, duid)
166
+ placed = Array(diagram&.elements).select { |el| el.model_element_ref == ref }
167
+ named = placed.select { |el| duid && el.duid.to_s.casecmp?(duid.to_s) }
168
+ placed = named unless named.empty?
169
+ placed.any? && placed.all? { |el| el.line_width == 2 || el.style.to_h[:lwth] == "2" }
170
+ end
171
+ private_class_method :border_two?
172
+
106
173
  def outline_connector?(connector, bounds)
174
+ return true if default_model_connector?(connector)
107
175
  return true if %w[Generalization Aggregation].include?(connector.connector_type)
108
176
 
177
+ return true if association_ray_outline?(connector)
109
178
  return false unless stored_association?(connector) || offset_free_association?(connector)
110
179
 
111
180
  # Diagonal Association rounding and tree docks keep their existing rules.
@@ -114,6 +183,15 @@ module Ea
114
183
  end
115
184
  private_class_method :outline_connector?
116
185
 
186
+ # The QEA builder marks the stored Association rays it produced;
187
+ # their diagonal docks sit on the outer stroke edge like the
188
+ # axis-aligned ones. Unmarked diagonals keep the legacy docking.
189
+ def association_ray_outline?(connector)
190
+ connector.connector_type == "Association" &&
191
+ connector.style.to_h[:association_ray_outline] == true
192
+ end
193
+ private_class_method :association_ray_outline?
194
+
117
195
  def stored_association?(connector)
118
196
  style = connector.style.to_h
119
197
  connector.connector_type == "Association" && connector.has_geometry_offsets &&
@@ -197,6 +275,7 @@ module Ea
197
275
  bounds_map, divider_y_by_ref)
198
276
  return nil unless bounds_map && divider_y_by_ref
199
277
  return nil unless pairs.size == 2
278
+ return nil if explicit_association_ray?(pairs, connector)
200
279
 
201
280
  rel = relationship_for(connector, model_index)
202
281
  return nil unless rel
@@ -231,16 +310,36 @@ module Ea
231
310
  end
232
311
  private_class_method :explicit_horizontal_source_dock?
233
312
 
234
- def explicit_horizontal_ray?(connector)
235
- explicit_horizontal_ray_style?(connector) && connector.has_geometry_offsets &&
313
+ # A stored Association ray whose source port certifies the shared
314
+ # anchor row is one explicit horizontal line: EA seats neither end
315
+ # in a compartment row, unlike Generalization/Aggregation targets.
316
+ def explicit_association_ray?(pairs, connector)
317
+ produced = connector.style.to_h[:stored_association_ray] == true
318
+ return true if produced && pairs.first[1] == pairs.last[1]
319
+
320
+ certified_association_ray?(pairs, connector)
321
+ end
322
+
323
+ def certified_association_ray?(pairs, connector)
324
+ return false unless explicit_horizontal_ray?(connector, %w[Association])
325
+
326
+ connector.source_anchor.y == connector.target_anchor.y &&
327
+ connector.source_port.y == connector.source_anchor.y &&
328
+ pairs.first[1] == pairs.last[1]
329
+ end
330
+ private_class_method :certified_association_ray?
331
+ private_class_method :explicit_association_ray?
332
+
333
+ def explicit_horizontal_ray?(connector, types = %w[Generalization Aggregation])
334
+ explicit_horizontal_ray_style?(connector, types) && connector.has_geometry_offsets &&
236
335
  connector.source_port && connector.target_port &&
237
336
  connector.source_anchor && connector.target_anchor
238
337
  end
239
338
  private_class_method :explicit_horizontal_ray?
240
339
 
241
- def explicit_horizontal_ray_style?(connector)
340
+ def explicit_horizontal_ray_style?(connector, types)
242
341
  style = connector.style.to_h
243
- %w[Generalization Aggregation].include?(connector.connector_type) &&
342
+ types.include?(connector.connector_type) &&
244
343
  style[:mode] == "3" && !style[:tree] && !style[:direct] && !style[:regenerated]
245
344
  end
246
345
  private_class_method :explicit_horizontal_ray_style?
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.99"
7
+ VERSION = "0.6.101"
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.99
4
+ version: 0.6.101
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.