ea 0.6.98 → 0.6.100

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: b3c13e631927ad57c6168b6d7c02eaed8e933c89ad7dfcd0a055fb6b8a69e8e1
4
- data.tar.gz: 3edb35912780be1170fce402dd141823f4c5e44c92a6f84451fa136d3eb026a8
3
+ metadata.gz: 8f60cf58bb521e911eb26fb30b628162e149a9521027fae0e77756ef1b8354dd
4
+ data.tar.gz: c0580ced4122e41650fbc5053fb0fb534a40c8cae84f758039dd8b2f575422ad
5
5
  SHA512:
6
- metadata.gz: 6544102e41ff1bde04737e18f93de5ea0ee657c47335f7eeb12316162ca33fbf2e0d5b0dc1572fccc5174373781b1368508d606641286c88ebe2d7f9f2232079
7
- data.tar.gz: eb3cb4702ea44ba8759ab9774a1ca445684b9d6ce7043153b4823657c191fff45c6db267ee74b1ed20cdec2064248b60a0f5112827b92edfa4aaecaa495fa093
6
+ metadata.gz: 6460bd84357b97c350c485526bcc62bc74309d924eacd9e7983347b101c22d1fcfc246dc7b76d443eb04cb360e2be890eaf1cffedf49103e9f2419b5205fe3a7
7
+ data.tar.gz: 2a8ce3556ffa2b5531e432002ab7a67439cd4f4ed171c1f84f141b264e9fa6913af3ffd8983dbb0be70c9282407fdb3695efb07728c0217c656688ed38f8d023
@@ -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,134 @@ 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)
117
+ return model unless origins
118
+
119
+ model.waypoints = [model.source_port, model.target_port].map do |point|
120
+ Ea::Model::Waypoint.new(position: point)
121
+ end
122
+ model.source_anchor, model.target_anchor = origins
123
+ model
124
+ end
125
+
126
+ def association_ray_origins(source, target, geom)
127
+ origins = [stored_ray_origin(source, geom[:sx], geom[:sy]),
128
+ 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])
131
+ end
132
+
133
+ def association_ray_candidate?(model, link_row)
134
+ 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)
137
+ end
138
+
139
+ def association_ray_style?(style)
140
+ style[:mode] == "3" && !style[:tree] && %w[0 2].include?(style[:lwidth]) &&
141
+ %i[direct regenerated].none? { |key| style.key?(key) }
142
+ end
143
+
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
146
+ # unless the line is axis-aligned, which no rounding changes.
147
+ def sub_pixel_safe_origins?(origins, boxes)
148
+ first, last = origins
149
+ return false if first == last
150
+
151
+ axis_aligned = first.x == last.x || first.y == last.y
152
+ axis_aligned || origins.zip(boxes).none? { |point, box| on_border?(point, box) }
153
+ end
154
+
155
+ def on_border?(point, bounds)
156
+ [bounds.x, bounds.x + bounds.width].include?(point.x) ||
157
+ [bounds.y, bounds.y + bounds.height].include?(point.y)
158
+ end
159
+
160
+ def inside_box?(point, bounds)
161
+ point.x.between?(bounds.x, bounds.x + bounds.width) &&
162
+ point.y.between?(bounds.y, bounds.y + bounds.height)
163
+ end
164
+
165
+ # A Mode 2 Association with equal SX/EX (vertical EDGE 1/3) or
166
+ # equal SY/EY (horizontal EDGE 4) and no custom Path docks as a
167
+ # straight line through the middle of the boxes' overlap, moved
168
+ # by the shared stored offset. Stored SY counts upward.
169
+ def overlap_ray_origins(model, link_row, diagram_row, geom, context)
170
+ return model if context
171
+ return model unless overlap_ray_candidate?(model, link_row, geom)
172
+
173
+ source, target = stored_ray_boxes(link_row, diagram_row)
174
+ origins = overlap_origins(source, target, geom)
175
+ return model unless origins
176
+
177
+ model.source_anchor, model.target_anchor = origins.map do |ox, oy|
178
+ Ea::Model::Point.new(x: ox, y: oy)
179
+ end
180
+ model.style[:overlap_ray] = true
181
+ model
182
+ end
183
+
184
+ # Both ray origins share the overlap coordinate on the axis the
185
+ # offsets move and sit at each box's centre on the other axis.
186
+ def overlap_origins(source, target, geom)
187
+ along, across, offset = overlap_axes(geom)
188
+ coordinate = overlap_ray_coordinate(*box_span(source, along), *box_span(target, along), offset)
189
+ return nil unless coordinate && disjoint_span?(*box_span(source, across), *box_span(target, across))
190
+
191
+ [source, target].map do |box|
192
+ start, size = box_span(box, across)
193
+ centre = start + size / 2.0
194
+ geom[:edge] == 4 ? [centre, coordinate] : [coordinate, centre]
195
+ end
196
+ end
197
+
198
+ # [axis the offsets move, axis the boxes face, stored offset]
199
+ def overlap_axes(geom)
200
+ return [%i[y height], %i[x width], -geom[:sy]] if geom[:edge] == 4
201
+
202
+ [%i[x width], %i[y height], geom[:sx]]
203
+ end
204
+
205
+ def box_span(box, keys)
206
+ keys.map { |key| box.public_send(key) }
207
+ end
208
+
209
+ def overlap_ray_candidate?(model, link_row, geom)
210
+ model.connector_type == "Association" && model.style[:mode] == "2" &&
211
+ model.waypoints.size == 2 && link_row.path.to_s.empty? &&
212
+ model.has_geometry_offsets && uniform_overlap_offsets?(geom)
213
+ end
214
+
215
+ def uniform_overlap_offsets?(geom)
216
+ return false unless geom.values_at(:sx, :sy, :ex, :ey).all?
217
+
218
+ geom[:edge] == 4 ? geom[:sy] == geom[:ey] : [1, 3].include?(geom[:edge]) && geom[:sx] == geom[:ex]
219
+ end
220
+
221
+ # Floor of the overlap midpoint plus the shared offset; nil
222
+ # when the boxes do not overlap on this axis.
223
+ def overlap_ray_coordinate(start_a, size_a, start_b, size_b, offset)
224
+ low = [start_a, start_b].max
225
+ high = [start_a + size_a, start_b + size_b].min
226
+ return nil if low > high
227
+
228
+ coordinate = ((low + high) / 2.0 + offset).floor
229
+ coordinate if coordinate.between?(low, high)
230
+ end
231
+
232
+ def disjoint_span?(start_a, size_a, start_b, size_b)
233
+ start_a + size_a < start_b || start_b + size_b < start_a
234
+ end
235
+
58
236
  def stored_ray_boxes(link_row, diagram_row)
59
237
  connector = database.find_connector(link_row.connectorid)
60
238
  [source_placement_for(connector, diagram_row),
@@ -115,6 +293,85 @@ module Ea
115
293
  y: bounds.y + bounds.height / 2 - y_offset.to_i)
116
294
  end
117
295
 
296
+ # A vertical tree stores its trunk bend twice when the parent sits
297
+ # directly above; EA draws that as one bend.
298
+ def drop_zero_length_tree_segments(points, link_row)
299
+ return points unless points.size == 4 && points[1] == points[2] &&
300
+ DiagramStyleParser.parse(link_row.style)[:tree] == "V"
301
+
302
+ points.values_at(0, 1, 3)
303
+ end
304
+
305
+ # A stored vertical tree trunk docks both ends on the box edges that
306
+ # face its bends, at the adjacent bend's x: Aggregations without
307
+ # geometry offsets whose child sits below, and flush Associations
308
+ # whose parent sits above. Other shapes keep their stored docks.
309
+ def redock_vertical_tree_trunk(points, link_row, connector, source_placement, target_placement)
310
+ style = DiagramStyleParser.parse(link_row.style)
311
+ return unless points.size == 4 && style[:tree] == "V"
312
+
313
+ case connector.connector_type
314
+ when "Aggregation"
315
+ return unless style[:lwidth] == "0" || !geometry_has_offsets?(link_row.geometry)
316
+
317
+ redock_child_below(points, bounds_from_rect(source_placement), bounds_from_rect(target_placement))
318
+ when "Association"
319
+ return unless style[:lwidth] == "0"
320
+
321
+ redock_parent_above(points, link_row, source_placement, target_placement, strict: true)
322
+ end
323
+ end
324
+
325
+ def redock_child_below(points, src_b, tgt_b)
326
+ first_bend, last_bend = points.values_at(1, -2)
327
+ source_y = src_b.y + src_b.height
328
+ return points unless source_y <= tgt_b.y && first_bend[1] > source_y && last_bend[1] < tgt_b.y
329
+
330
+ points[0] = [clamp_to_span(first_bend[0], src_b), source_y]
331
+ points[-1] = [clamp_to_span(last_bend[0], tgt_b), tgt_b.y]
332
+ points
333
+ end
334
+
335
+ def clamp_to_span(position, bounds)
336
+ position.clamp(bounds.x, bounds.x + bounds.width)
337
+ end
338
+
339
+ # A stored-Path Mode 3 width-2 end whose adjacent bend lies level
340
+ # with its box (inside the box's span on one axis, outside on the
341
+ # other) enters the box orthogonally on the outer outline at that
342
+ # bend's coordinate. Bends that are not level keep the port and
343
+ # every authored corner stays.
344
+ def level_entry_route(points, link_row, connector, src, tgt)
345
+ return points unless points.size >= 3 && level_entry_style?(link_row, connector)
346
+
347
+ route = points.dup
348
+ first = level_entry_point(route[1], src)
349
+ last = level_entry_point(route[-2], tgt)
350
+ route[0] = first if first
351
+ route[-1] = last if last
352
+ route
353
+ end
354
+
355
+ def level_entry_style?(link_row, connector)
356
+ style = DiagramStyleParser.parse(link_row.style)
357
+ %w[Generalization Aggregation].include?(connector.connector_type) &&
358
+ style[:mode] == "3" && style[:lwidth] == "2" && !style.key?(:tree)
359
+ end
360
+
361
+ def level_entry_point(bend, box)
362
+ level_y = bend[1] > box.y && bend[1] < box.y + box.height
363
+ level_x = bend[0] > box.x && bend[0] < box.x + box.width
364
+ if level_y && outside_span?(bend[0], box.x, box.width)
365
+ [bend[0] < box.x ? box.x - 1 : box.x + box.width + 1, bend[1]]
366
+ elsif level_x && outside_span?(bend[1], box.y, box.height)
367
+ [bend[0], bend[1] < box.y ? box.y - 1 : box.y + box.height + 1]
368
+ end
369
+ end
370
+
371
+ def outside_span?(value, start, length)
372
+ value < start || value > start + length
373
+ end
374
+
118
375
  # Square routing keeps the stored orthogonal trunk, even when a
119
376
  # shorter facing-edge route exists between the same boxes.
120
377
  def stored_square_route(link_row, connector, source, target)
@@ -164,19 +421,29 @@ module Ea
164
421
 
165
422
  # A valid lateral corner joins one vertical leg and one horizontal
166
423
  # leg. Keep it even when the boxes also admit a shorter direct route.
167
- def stored_lateral_route(link_row, connector, source, target)
424
+ # ported: both ends carry explicit SX/SY and EX/EY offsets. An
425
+ # untyped corner without them may be an authored diagonal.
426
+ def stored_lateral_route(link_row, connector, source, target, ported: false)
168
427
  return nil unless %w[Aggregation Association].include?(connector.connector_type)
169
428
 
170
429
  style = DiagramStyleParser.parse(link_row.style)
171
- return nil unless stored_lateral_style?(style)
430
+ return nil unless stored_lateral_style?(style, connector, ported)
172
431
 
173
432
  corner = stored_lateral_corner(link_row)
174
- return nil unless corner
433
+ corner && lateral_corner_route(style, corner, source, target)
434
+ end
175
435
 
176
- if style[:tree] == "LH"
177
- horizontal_corner_route(corner, source, target)
436
+ # An untyped Mode 3 Association corner takes whichever leg
437
+ # orientation its position fits; width-2 docks sit on the 1px
438
+ # outer outline.
439
+ def lateral_corner_route(style, corner, source, target)
440
+ case style[:tree]
441
+ when "LH" then horizontal_corner_route(corner, source, target)
442
+ when "LV" then vertical_corner_route(corner, source, target)
178
443
  else
179
- vertical_corner_route(corner, source, target)
444
+ outline = style[:lwidth] == "2" ? 1 : 0
445
+ horizontal_corner_route(corner, source, target, outline) ||
446
+ vertical_corner_route(corner, source, target, outline)
180
447
  end
181
448
  end
182
449
 
@@ -186,8 +453,11 @@ module Ea
186
453
  intermediate_waypoints(link_row).first
187
454
  end
188
455
 
189
- def stored_lateral_style?(style)
190
- style[:mode] == "3" && %w[0 2].include?(style[:lwidth]) && %w[LH LV].include?(style[:tree])
456
+ def stored_lateral_style?(style, connector, ported)
457
+ return false unless style[:mode] == "3" && %w[0 2].include?(style[:lwidth])
458
+
459
+ %w[LH LV].include?(style[:tree]) ||
460
+ (style[:tree].nil? && ported && connector.connector_type == "Association")
191
461
  end
192
462
 
193
463
  def horizontal_corner?(corner, source, target)
@@ -195,13 +465,13 @@ module Ea
195
465
  !within_height?(corner[1], source) && !within_horizontal_span?(corner, target)
196
466
  end
197
467
 
198
- def horizontal_corner_route(corner, source, target)
468
+ def horizontal_corner_route(corner, source, target, outline = 0)
199
469
  return nil unless horizontal_corner?(corner, source, target)
200
470
 
201
471
  x, y = corner
202
472
 
203
- source_y = y < source.y ? source.y : source.y + source.height
204
- target_x = x < target.x ? target.x : target.x + target.width
473
+ source_y = y < source.y ? source.y - outline : source.y + source.height + outline
474
+ target_x = x < target.x ? target.x - outline : target.x + target.width + outline
205
475
  [[x, source_y], corner, [target_x, y]]
206
476
  end
207
477
 
@@ -210,13 +480,13 @@ module Ea
210
480
  !within_horizontal_span?(corner, source) && !within_height?(corner[1], target)
211
481
  end
212
482
 
213
- def vertical_corner_route(corner, source, target)
483
+ def vertical_corner_route(corner, source, target, outline = 0)
214
484
  return nil unless vertical_corner?(corner, source, target)
215
485
 
216
486
  x, y = corner
217
487
 
218
- source_x = x < source.x ? source.x : source.x + source.width
219
- target_y = y < target.y ? target.y : target.y + target.height
488
+ source_x = x < source.x ? source.x - outline : source.x + source.width + outline
489
+ target_y = y < target.y ? target.y - outline : target.y + target.height + outline
220
490
  [[source_x, y], corner, [x, target_y]]
221
491
  end
222
492
 
@@ -242,14 +512,13 @@ module Ea
242
512
  # Keep a vertical tree trunk whose bends still fit both boxes
243
513
  # and the open gap; the closest facing edges need not share its x.
244
514
  def preserve_stored_tree?(points, link_row, connector, src, tgt)
245
- return false unless connector.connector_type == "Aggregation" && points.size == 4
515
+ return false unless points.size == 4 && stored_tree_type?(points, link_row, connector)
246
516
 
247
517
  style = DiagramStyleParser.parse(link_row.style)
248
518
  return false unless style[:lwidth] == "2" || flush_width_zero_tree?(link_row)
249
- return false unless style[:tree] == "V" ||
250
- (style[:mode] == "3" && style[:tree].nil?)
519
+ return false unless vertical_tree_style?(style)
251
520
 
252
- tree_bends_fit?(points[1], points[-2], src, tgt)
521
+ tree_bends_fit?(points[1], points[-2], src, tgt, flush_width_zero_tree?(link_row))
253
522
  end
254
523
 
255
524
  # A width-2 Mode 3 horizontal tree keeps its stored vertical trunk
@@ -320,6 +589,38 @@ module Ea
320
589
  model
321
590
  end
322
591
 
592
+ # Same one-pixel collapse for a two-waypoint computed line when only
593
+ # the target carries an explicit port (the source end is the default
594
+ # edge midpoint): the left-hand end's y wins.
595
+ def collapse_partial_port_jog(model, link_row, diagram_row)
596
+ return model unless partial_port_jog_candidate?(model, link_row)
597
+
598
+ connection = database.find_connector(link_row.connectorid)
599
+ src, tgt = [connection.start_object_id, connection.end_object_id].map do |id|
600
+ placement = diagram_object_placement(diagram_row.diagram_id, id)
601
+ placement && bounds_from_rect(placement)
602
+ end
603
+ from, to = model.waypoints.map(&:position)
604
+ return model unless src && tgt && facing_jog_ports?(from, to, src, tgt)
605
+
606
+ level = [from, to].min_by(&:x).y
607
+ ends = [[from.x, level], [to.x, level]].map do |x, y|
608
+ Ea::Model::Point.new(x: x, y: y)
609
+ end
610
+ model.waypoints = ends.map { |point| Ea::Model::Waypoint.new(position: point) }
611
+ model.source_anchor = model.source_port = ends.first
612
+ model.target_anchor = model.target_port = ends.last
613
+ model
614
+ end
615
+
616
+ def partial_port_jog_candidate?(model, link_row)
617
+ style = model.style.to_h
618
+ 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
622
+ end
623
+
323
624
  def computed_jog_candidate?(model, link_row, geom)
324
625
  style = model.style.to_h
325
626
  model.connector_type == "Generalization" && geom[:edge] == 2 &&
@@ -346,14 +647,32 @@ module Ea
346
647
  style[:mode] == "3" && style[:tree] == "V" && style[:lwidth] == "0"
347
648
  end
348
649
 
349
- def tree_bends_fit?(first, last, source, target)
350
- first[0] != last[0] && first[1] == last[1] &&
351
- within_vertical_gap?(first[1], source, target) &&
650
+ def vertical_tree_style?(style)
651
+ style[:tree] == "V" || (style[:mode] == "3" && style[:tree].nil?)
652
+ end
653
+
654
+ def stored_tree_type?(points, link_row, connector)
655
+ connector.connector_type == "Aggregation" || flush_straight_association?(points, link_row, connector)
656
+ end
657
+
658
+ # A flush Association keeps only a straight trunk (one bend stored twice).
659
+ def flush_straight_association?(points, link_row, connector)
660
+ connector.connector_type == "Association" && points[1] == points[-2] &&
661
+ flush_width_zero_tree?(link_row)
662
+ end
663
+
664
+ # A flush trunk may also hang below its parent; a bend repeated at
665
+ # one x is a straight trunk with a single bend.
666
+ def tree_bends_fit?(first, last, source, target, flush)
667
+ first[1] == last[1] &&
668
+ within_vertical_gap?(first[1], source, target, flush) &&
352
669
  within_horizontal_span?(first, source) && within_horizontal_span?(last, target)
353
670
  end
354
671
 
355
- def within_vertical_gap?(height, source, target)
356
- height < source.y - 1 && height > target.y + target.height + 1
672
+ def within_vertical_gap?(height, source, target, flush)
673
+ return true if height < source.y - 1 && height > target.y + target.height + 1
674
+
675
+ flush && height > source.y + source.height + 1 && height < target.y - 1
357
676
  end
358
677
 
359
678
  def within_horizontal_span?(point, bounds)
@@ -421,8 +421,12 @@ module Ea
421
421
  has_geometry_offsets: geometry_has_offsets?(link_row.geometry),
422
422
  is_substitutable: substitutable_connector?(connector)
423
423
  ), link_row, diagram_row, geom)
424
+ model = collapse_partial_port_jog(model, link_row, diagram_row)
424
425
  model = direct_empty_path_ray(model, link_row, diagram_row, geom, context)
425
- correct_stored_ray_origins(model, link_row, diagram_row, geom, context)
426
+ model = mode2_overlap_ray(model, link_row, diagram_row, geom, context)
427
+ model = association_stored_ray(model, link_row, diagram_row, geom, context)
428
+ model = correct_stored_ray_origins(model, link_row, diagram_row, geom, context)
429
+ overlap_ray_origins(model, link_row, diagram_row, geom, context)
426
430
  end
427
431
 
428
432
  # Ray origin for one connector end: the element's center
@@ -662,7 +666,8 @@ module Ea
662
666
  src = bounds_from_rect(source_placement)
663
667
  tgt = bounds_from_rect(target_placement)
664
668
  tree_route = stored_square_route(link_row, connector, src, tgt) ||
665
- stored_lateral_route(link_row, connector, src, tgt) ||
669
+ stored_lateral_route(link_row, connector, src, tgt,
670
+ ported: source_port && target_port) ||
666
671
  horizontal_tree_route(points, link_row, connector, src, tgt)
667
672
  if tree_route
668
673
  points = tree_route
@@ -681,8 +686,15 @@ module Ea
681
686
  redock_parent_above(points, link_row, source_placement,
682
687
  target_placement, strict: true)
683
688
  end
689
+ if intermediate.any?
690
+ redock_vertical_tree_trunk(points, link_row, connector, source_placement, target_placement)
691
+ end
684
692
  end
685
693
 
694
+ if intermediate.any? && !tree_route
695
+ points = level_entry_route(points, link_row, connector, src, tgt)
696
+ end
697
+ points = drop_zero_length_tree_segments(points, link_row)
686
698
  points.map do |x, y|
687
699
  Ea::Model::Waypoint.new(position: Ea::Model::Point.new(x: x, y: y))
688
700
  end
@@ -42,7 +42,7 @@ module Ea
42
42
  anchors = [connector.source_anchor, connector.target_anchor]
43
43
  bounds = [source_bounds, target_bounds]
44
44
  original = clipped_pairs(bounds, anchors)
45
- return original unless stroke_outline?(connector, bounds)
45
+ return original unless stroke_outline?(connector, bounds) || overlap_ray?(connector)
46
46
 
47
47
  # EA docks these markers on the outer edge of the 2px box stroke.
48
48
  outline = bounds.map do |box|
@@ -103,6 +103,14 @@ module Ea
103
103
  end
104
104
  private_class_method :stroke_outline?
105
105
 
106
+ # The QEA builder marks the Mode 2 Association overlap rays it
107
+ # produced; EA docks them on the outer stroke edge whatever the
108
+ # line width. Naturally aligned centres keep the legacy docking.
109
+ def overlap_ray?(connector)
110
+ connector.connector_type == "Association" && connector.style.to_h[:overlap_ray] == true
111
+ end
112
+ private_class_method :overlap_ray?
113
+
106
114
  def outline_connector?(connector, bounds)
107
115
  return true if %w[Generalization Aggregation].include?(connector.connector_type)
108
116
 
@@ -197,6 +205,7 @@ module Ea
197
205
  bounds_map, divider_y_by_ref)
198
206
  return nil unless bounds_map && divider_y_by_ref
199
207
  return nil unless pairs.size == 2
208
+ return nil if explicit_association_ray?(pairs, connector)
200
209
 
201
210
  rel = relationship_for(connector, model_index)
202
211
  return nil unless rel
@@ -231,16 +240,28 @@ module Ea
231
240
  end
232
241
  private_class_method :explicit_horizontal_source_dock?
233
242
 
234
- def explicit_horizontal_ray?(connector)
235
- explicit_horizontal_ray_style?(connector) && connector.has_geometry_offsets &&
243
+ # A stored Association ray whose source port certifies the shared
244
+ # anchor row is one explicit horizontal line: EA seats neither end
245
+ # in a compartment row, unlike Generalization/Aggregation targets.
246
+ def explicit_association_ray?(pairs, connector)
247
+ return false unless explicit_horizontal_ray?(connector, %w[Association])
248
+
249
+ connector.source_anchor.y == connector.target_anchor.y &&
250
+ connector.source_port.y == connector.source_anchor.y &&
251
+ pairs.first[1] == pairs.last[1]
252
+ end
253
+ private_class_method :explicit_association_ray?
254
+
255
+ def explicit_horizontal_ray?(connector, types = %w[Generalization Aggregation])
256
+ explicit_horizontal_ray_style?(connector, types) && connector.has_geometry_offsets &&
236
257
  connector.source_port && connector.target_port &&
237
258
  connector.source_anchor && connector.target_anchor
238
259
  end
239
260
  private_class_method :explicit_horizontal_ray?
240
261
 
241
- def explicit_horizontal_ray_style?(connector)
262
+ def explicit_horizontal_ray_style?(connector, types)
242
263
  style = connector.style.to_h
243
- %w[Generalization Aggregation].include?(connector.connector_type) &&
264
+ types.include?(connector.connector_type) &&
244
265
  style[:mode] == "3" && !style[:tree] && !style[:direct] && !style[:regenerated]
245
266
  end
246
267
  private_class_method :explicit_horizontal_ray_style?
@@ -22,6 +22,10 @@ module Ea
22
22
  SEAT_FONT_SIZE = 7
23
23
  SEAT_X_INSET = 3
24
24
  SEAT_STEREOTYPE_GAP = 12
25
+ # EA keeps an upward property seat's left edge at diagram x >= 3;
26
+ # the stereotype line moves with the role. Measured only for the
27
+ # default Cambria 7pt boxed cluster.
28
+ MIN_ROLE_X = 3
25
29
 
26
30
  attr_reader :canvas, :model_index, :document, :theme,
27
31
  :font_family, :font_size, :font_unit
@@ -93,7 +97,8 @@ module Ea
93
97
  mult_pos = directional_multiplicity(connector, direction, anchor, mult_box, mult) ||
94
98
  mult_seat(direction, anchor, mult_box) || mult_pos
95
99
  elsif end_kind == :target && !(tree && zero_offset?(text_box, mult_box))
96
- text_pos = target_upward_role(connector, heading, anchor, text_box, role) || text_pos
100
+ text_pos = target_vertical_role(connector, heading, anchor, text_box, role) ||
101
+ target_rightward_role(heading, anchor, text_box, role) || text_pos
97
102
  mult_pos = target_multiplicity_seat(heading, anchor, mult_box) || mult_pos
98
103
  end
99
104
 
@@ -187,7 +192,9 @@ module Ea
187
192
 
188
193
  x, y = seat_corner(anchor, box)
189
194
  inset, baseline = property_role_offsets(direction)
190
- [x - inset - width, y + baseline]
195
+ left = x - inset - width
196
+ left = [left, MIN_ROLE_X].max if direction == :up
197
+ [left, y + baseline]
191
198
  end
192
199
 
193
200
  def plain_upward_seat(connector, direction, anchor, box, role)
@@ -203,34 +210,59 @@ module Ea
203
210
  [x - SEAT_X_INSET - width, y - 6]
204
211
  end
205
212
 
206
- def target_upward_role(connector, heading, anchor, box, role)
207
- return nil unless heading && heading[0].zero? && heading[1].negative?
213
+ def target_vertical_role(connector, heading, anchor, box, role)
214
+ return nil unless heading && heading[0].zero? && !heading[1].zero?
215
+
216
+ direction = heading[1].negative? ? :target_up : :target_down
217
+ plain_vertical_role(connector, direction, anchor, box, role)
218
+ end
208
219
 
209
- plain_vertical_role(connector, :target_up, anchor, box, role)
220
+ # A target whose leg arrives from the right seats its role like a
221
+ # source whose leg leaves to the right.
222
+ def target_rightward_role(heading, anchor, box, role)
223
+ return nil unless heading && heading[1].zero? && heading[0].positive?
224
+ return nil unless seat_font? && cambria_box?(box) && !role_empty?(role)
225
+
226
+ x, y = seat_corner(anchor, box)
227
+ [x + SEAT_X_INSET, y - 7]
210
228
  end
211
229
 
212
230
  def plain_vertical_role(connector, direction, anchor, box, role)
213
- return nil unless %i[down target_up].include?(direction) && plain_vertical_box?(box)
231
+ return nil unless %i[down target_up target_down].include?(direction) &&
232
+ plain_vertical_box?(box)
214
233
 
215
234
  end_kind = direction == :down ? :source : :target
216
- return nil unless plain_vertical_connector?(connector, end_kind)
235
+ return nil unless plain_vertical_connector?(connector, end_kind, direction)
217
236
 
218
237
  width = Ea::Fonts::Metrics.text_length(role, font_size, family: font_family)
219
238
  return nil unless width
220
239
 
221
240
  x, y = seat_corner(anchor, box)
222
- [x - width - 8, y + (end_kind == :source ? 15 : -7)]
241
+ [x - width - 8, y + (direction == :target_up ? -7 : 15)]
223
242
  end
224
243
 
225
244
  def plain_vertical_box?(box)
226
245
  seat_font? && cambria_box?(box) && [0, 1].include?(box["alignment"])
227
246
  end
228
247
 
229
- def plain_vertical_connector?(connector, end_kind)
230
- style = connector.style.to_h
231
- connector.connector_type == "Association" &&
232
- connector_source_dest_stereotype(connector, end_kind).empty? &&
233
- style[:mode] == "3" && style[:lwidth] == "0" && !style[:tree]
248
+ # A target leg that runs down seats only on the "LV" tree route
249
+ # with line width 2; every other vertical seat needs the plain
250
+ # width-0 route.
251
+ def plain_vertical_connector?(connector, end_kind, direction)
252
+ return false unless connector.connector_type == "Association" &&
253
+ connector_source_dest_stereotype(connector, end_kind).empty?
254
+
255
+ plain_vertical_style?(connector.style.to_h, direction)
256
+ end
257
+
258
+ def plain_vertical_style?(style, direction)
259
+ return false unless style[:mode] == "3"
260
+
261
+ if direction == :target_down
262
+ style[:lwidth] == "2" && style[:tree] == "LV"
263
+ else
264
+ style[:lwidth] == "0" && !style[:tree]
265
+ end
234
266
  end
235
267
 
236
268
  def plain_role_route?(style)
@@ -261,7 +293,7 @@ module Ea
261
293
  case direction
262
294
  when :left then left_property_route?(style)
263
295
  when :down then downward_property_route?(style)
264
- else style[:lwidth] == "2" && style[:tree] != "OS"
296
+ else %w[0 2].include?(style[:lwidth]) && style[:tree] != "OS"
265
297
  end
266
298
  end
267
299
 
@@ -97,7 +97,7 @@ module Ea
97
97
  mult_box: tgt_mb,
98
98
  anchor: target_pt, connector: connector,
99
99
  end_kind: :target, tree: tree && tree[:target],
100
- heading: stored_tgt && leg_heading(points.reverse),
100
+ heading: stored_tgt && target_heading(connector, points),
101
101
  stored_labels: stored_labels).each { |t| texts << t }
102
102
  texts
103
103
  end
@@ -126,6 +126,19 @@ module Ea
126
126
  stored_upward_heading(connector, heading)
127
127
  end
128
128
 
129
+ # A target dock that clips an axis-aligned stored leg into a slight
130
+ # diagonal keeps the stored leg's direction.
131
+ def target_heading(connector, points)
132
+ heading = leg_heading(points.reverse)
133
+ return heading unless points.size == 2 && heading.none?(&:zero?)
134
+
135
+ stored = waypoint_pairs(connector)
136
+ return heading unless stored.size == 2
137
+
138
+ original = leg_heading(stored.reverse)
139
+ original.any?(&:zero?) ? original : heading
140
+ end
141
+
129
142
  def stored_upward_heading(connector, heading)
130
143
  return heading unless heading[1].negative?
131
144
 
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.98"
7
+ VERSION = "0.6.100"
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.98
4
+ version: 0.6.100
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.