ea 0.6.104 → 0.6.106

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: 53866c904e4fef2b317cd5aeb9e0b142e3d970507b65e9d9f14d2c768a56f1e8
4
- data.tar.gz: 40ad1c86acd5461a89fe8e6b23a9da1f65eb43ef9eba632583344d09ed54b932
3
+ metadata.gz: 944b29164fec6d17c5e89224547ae993a7ef4dfbee74ac1c15208eba6f88f0d5
4
+ data.tar.gz: e0d40f4203d9ef1bf8a1d6f0cf72349b7e3e052ed2c3c920f96f1ca655ff2741
5
5
  SHA512:
6
- metadata.gz: f475a015d57fc2337ef3a19ea8e81fbd75e2f04532d79893e5fc01e1e9b344b07b973e4b833a4480dc49aed31f1ff4b45463950e53dc5e290f2b7304638be9e9
7
- data.tar.gz: b8ca9731d6d2442f74778cbe4ad66550cada64399cbf33eb858a4e0ad59ad7e9488516732194d29ebaa7733c3beae401ae7e7856964b3d1c4fd0989abae0db6d
6
+ metadata.gz: 4087c56867c690c67f9c99011a3e8bd4c043fdcb4bb25880a6d7020d7bab5c6e9bed6adc5b25475e37d9dfe64034d6ce7a52fea44d163fb7a41df37998ce1186
7
+ data.tar.gz: 4ae9f0ff3fc328d2f4ceeec2a9e8b0acb51fc72326c9806b00471449dfc656d3b60029f002b4bd5e59269c32807144ed1a660dafb29bc9b4916731e5ba57e761
@@ -4,7 +4,7 @@ module Ea
4
4
  module Sources
5
5
  module Qea
6
6
  class DiagramBuilder
7
- # Source-led routes for auto-routed associations with unequal end offsets.
7
+ # Stored mathematical docking for auto-routed associations.
8
8
  module AutoRouting
9
9
  private
10
10
 
@@ -22,6 +22,53 @@ module Ea
22
22
  model
23
23
  end
24
24
 
25
+ # When the shared row misses the target, retain the physical ports
26
+ # and let the emitter clip their ray before inserting the elbow.
27
+ def auto_port_elbow(model, link_row, diagram_row, geom, context)
28
+ return model if context || !auto_port_elbow_candidate?(model, link_row, geom)
29
+
30
+ source, target = stored_ray_boxes(link_row, diagram_row)
31
+ origins = [stored_ray_origin(source, geom[:sx], geom[:sy]),
32
+ stored_ray_origin(target, geom[:ex], geom[:ey])]
33
+ return model unless auto_port_elbow_origins?(source, target, origins, geom)
34
+
35
+ apply_auto_port_elbow(model, origins)
36
+ end
37
+
38
+ def apply_auto_port_elbow(model, origins)
39
+ model.source_anchor, model.target_anchor = origins
40
+ model.waypoints = origins.map { |point| Ea::Model::Waypoint.new(position: point) }
41
+ model.style = model.style.to_h.merge(association_ray_outline: true, auto_port_elbow: true)
42
+ model
43
+ end
44
+
45
+ def auto_port_elbow_candidate?(model, link_row, geom)
46
+ auto_port_elbow_model?(model) && link_row.path.to_s.empty? &&
47
+ source_row_style?(model.style.to_h) && uniform_overlap_offsets?(geom) && geom[:edge] == 4
48
+ end
49
+
50
+ def auto_port_elbow_model?(model)
51
+ model.connector_type == "Association" && model.has_geometry_offsets &&
52
+ model.source_anchor && model.target_anchor && model.waypoints.size == 2
53
+ end
54
+
55
+ def auto_port_elbow_origins?(source, target, origins, geom)
56
+ return false if overlap_origins(source, target, geom)
57
+ return false unless source_row_faces_target?(source, target, geom[:edge])
58
+ return false unless origins.first.x == source.x - 1
59
+ return false if within_height?(origins.first.y, target)
60
+ return false unless crosses_target_horizontal_edge?(origins, target)
61
+
62
+ [source, target].zip(origins).all? { |box, origin| inside_box?(origin, grown_bounds(box, 1)) }
63
+ end
64
+
65
+ def crosses_target_horizontal_edge?(origins, target)
66
+ outline = grown_bounds(target, 1)
67
+ edge = Ea::Model::Bounds.new(x: outline.x, width: outline.width, height: 0,
68
+ y: origins.first.y < outline.y ? outline.y : outline.y + outline.height)
69
+ segment_crosses_box?(*origins, edge)
70
+ end
71
+
25
72
  def source_row_origins(source, target, geom)
26
73
  return nil unless source_row_faces_target?(source, target, geom[:edge])
27
74
 
@@ -10,7 +10,16 @@ module Ea
10
10
  private
11
11
 
12
12
  def restore_stored_square_ports(model, link_row, diagram_row, geom, context)
13
- return model if context || !stored_square_port_style?(model, link_row, geom)
13
+ return model if context
14
+
15
+ model = mark_orthogonal_origin_ray(model, link_row, diagram_row, geom)
16
+ return model if model.style.to_h[:orthogonal_origin_ray] == true
17
+
18
+ restore_global_square_ports(model, link_row, diagram_row, geom)
19
+ end
20
+
21
+ def restore_global_square_ports(model, link_row, diagram_row, geom)
22
+ return model unless stored_square_port_style?(model, link_row, geom)
14
23
 
15
24
  connector = database.find_connector(link_row.connectorid)
16
25
  points = stored_square_port_coordinates(connector)
@@ -25,6 +34,50 @@ module Ea
25
34
  model
26
35
  end
27
36
 
37
+ # A square route with no stored bends uses the original mathematical
38
+ # ray to choose its orthogonal dock axis at render time.
39
+ def mark_orthogonal_origin_ray(model, link_row, diagram_row, geom)
40
+ return model unless orthogonal_origin_style?(model, link_row, geom)
41
+
42
+ origins = orthogonal_math_origins(link_row, diagram_row, geom)
43
+ return model unless origins
44
+
45
+ model.source_anchor, model.target_anchor = origins
46
+ model.waypoints = origins.map { |point| Ea::Model::Waypoint.new(position: point) }
47
+ model.style = model.style.to_h.merge(orthogonal_origin_ray: true)
48
+ model
49
+ end
50
+
51
+ def orthogonal_math_origins(link_row, diagram_row, geom)
52
+ connector = database.find_connector(link_row.connectorid)
53
+ boxes = stored_square_port_boxes(connector, diagram_row)
54
+ return unless boxes && orthogonal_box_gap?(*boxes)
55
+
56
+ offsets = [[geom[:sx], geom[:sy]], [geom[:ex], geom[:ey]]]
57
+ origins = boxes.zip(offsets).map { |box, offset| stored_ray_origin(box, *offset) }
58
+ origins if orthogonal_origins_inside?(origins, boxes)
59
+ end
60
+
61
+ def orthogonal_origins_inside?(origins, boxes)
62
+ origins.zip(boxes).all? { |point, box| origin_inside_outline?(point, box) }
63
+ end
64
+
65
+ def orthogonal_origin_style?(model, link_row, geom)
66
+ %w[Association Generalization].include?(model.connector_type) && link_row.path.to_s.empty? &&
67
+ model.style.to_h.values_at(:mode, :tree, :lwidth) == %w[3 OS 2] &&
68
+ %i[sx sy ex ey].all? { |key| !geom[key].nil? }
69
+ end
70
+
71
+ def orthogonal_box_gap?(source, target)
72
+ [source, target].all? { |box| box.width.positive? && box.height.positive? } &&
73
+ (horizontal_ray_gap?(source, target) ^ vertical_ray_gap?(source, target))
74
+ end
75
+
76
+ def origin_inside_outline?(point, box)
77
+ point.x.between?(box.x - 1, box.x + box.width + 1) &&
78
+ point.y.between?(box.y - 1, box.y + box.height + 1)
79
+ end
80
+
28
81
  def stored_square_port_style?(model, link_row, geom)
29
82
  style = model.style.to_h
30
83
  model.connector_type == "Association" && link_row.path.to_s.empty? &&
@@ -443,6 +443,7 @@ module Ea
443
443
  model = correct_stored_ray_origins(model, link_row, diagram_row, geom, context)
444
444
  model = source_row_association_ray(model, link_row, diagram_row, geom, context)
445
445
  model = overlap_ray_origins(model, link_row, diagram_row, geom, context)
446
+ model = auto_port_elbow(model, link_row, diagram_row, geom, context)
446
447
  model = mark_default_square_ray(model, link_row, geom, context)
447
448
  restore_stored_square_ports(model, link_row, diagram_row, geom, context)
448
449
  end
@@ -34,6 +34,9 @@ module Ea
34
34
  return nil unless source_bounds && target_bounds
35
35
  return nil if source_ref == target_ref
36
36
 
37
+ square = OrthogonalRay.pairs_for(connector, source_bounds, target_bounds)
38
+ return square if square
39
+
37
40
  if lateral
38
41
  target = lateral_target(points[1], target_bounds, connector.style[:tree])
39
42
  return target && [points[0], points[1], target]
@@ -56,13 +59,45 @@ module Ea
56
59
  width: box.width + 2, height: box.height + 2)
57
60
  end
58
61
  candidate = clipped_pairs(outline, anchors)
62
+ candidate[0] = external_source_point(connector, outline.first, anchors, candidate.first)
59
63
  expanded = forward_route?(original, candidate)
60
64
  pairs = expanded ? candidate : original
61
65
  target = expanded ? outline.last : target_bounds
62
66
  pairs = default_square_pairs(connector, points, source_bounds, target, pairs)
63
- stored_top_pairs(connector, expanded ? outline.first : source_bounds, pairs)
67
+ source = expanded ? outline.first : source_bounds
68
+ pairs = stored_top_pairs(connector, source, pairs)
69
+ auto_port_elbow_pairs(connector, source, target, pairs)
64
70
  end
65
71
 
72
+ # Auto routing turns at the clipped target column when the source
73
+ # leaves a side edge and the target enters through its top or bottom.
74
+ def auto_port_elbow_pairs(connector, source, target, pairs)
75
+ return pairs unless connector.style.to_h[:auto_port_elbow] == true
76
+
77
+ first, last = pairs
78
+ return pairs unless on_side_edge?(first, source) &&
79
+ [target.y, target.y + target.height].include?(last[1]) &&
80
+ last[0].between?(target.x, target.x + target.width)
81
+ return pairs if first[0] == last[0] || first[1] == last[1]
82
+
83
+ [first, [last[0], first[1]], last]
84
+ end
85
+ private_class_method :auto_port_elbow_pairs
86
+
87
+ # An external saved ray can cross two source edges; retain its
88
+ # clockwise perimeter choice instead of the nearest crossing.
89
+ def external_source_point(connector, source, anchors, fallback)
90
+ style = connector.style.to_h
91
+ return fallback unless style[:stored_association_ray] == true && style[:lwidth] == "2"
92
+
93
+ first, last = anchors
94
+ return fallback if first.x.between?(source.x, source.x + source.width) &&
95
+ first.y.between?(source.y, source.y + source.height)
96
+
97
+ anchor_exit(first, source, last, perimeter: true)
98
+ end
99
+ private_class_method :external_source_point
100
+
66
101
  # EDGE=1 explicitly seats an external source ray on the top edge;
67
102
  # its first contact can instead be the bottom when its origin is below.
68
103
  def stored_top_pairs(connector, source, pairs)
@@ -273,10 +308,9 @@ module Ea
273
308
  end
274
309
  private_class_method :forward_route?
275
310
 
276
- # Exit point of the anchor-to-anchor segment through a box's
277
- # outline: smallest positive t whose crossing lies on the
278
- # rectangle perimeter.
279
- def anchor_exit(anchor, bounds, other)
311
+ # Interior rays use the nearest forward exit. External saved sources
312
+ # can request the first intersecting edge in clockwise perimeter order.
313
+ def anchor_exit(anchor, bounds, other, perimeter: false)
280
314
  ax, ay = anchor.x.to_f, anchor.y.to_f
281
315
  ox, oy = other.x.to_f, other.y.to_f
282
316
  dx, dy = ox - ax, oy - ay
@@ -297,12 +331,25 @@ module Ea
297
331
  x >= bounds.x - 0.01 && x <= bounds.x + bounds.width + 0.01 &&
298
332
  y >= bounds.y - 0.01 && y <= bounds.y + bounds.height + 0.01
299
333
  end
300
- best = on_perimeter.min_by(&:first)
334
+ best = if perimeter
335
+ on_perimeter.min_by { |_t, x, y| perimeter_order(x, y, bounds) }
336
+ else
337
+ on_perimeter.min_by(&:first)
338
+ end
301
339
  # EA floors the dock coordinates (F851A657: 297.75 -> 297,
302
340
  # 433.7 -> 433) - a rounded-up dock is a visible miss.
303
341
  best ? [best[1].floor, best[2].floor] : [ax.floor, ay.floor]
304
342
  end
305
343
 
344
+ def perimeter_order(point_x, point_y, bounds)
345
+ return 0 if point_y == bounds.y
346
+ return 1 if point_x == bounds.x + bounds.width
347
+ return 2 if point_y == bounds.y + bounds.height
348
+
349
+ 3
350
+ end
351
+ private_class_method :perimeter_order
352
+
306
353
  # EA never docks a horizontal connector line at the header
307
354
  # divider: endpoints landing on (box side edge, divider_y)
308
355
  # move down one row slot to divider_y + 13 - the first
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ea
4
+ module Svg
5
+ module EaEmitter
6
+ # Orthogonal docking from the nearer physical intersection of a stored ray.
7
+ module OrthogonalRay
8
+ module_function
9
+
10
+ def pairs_for(connector, source, target)
11
+ return unless eligible?(connector, source, target)
12
+
13
+ origins = [connector.source_anchor, connector.target_anchor]
14
+
15
+ boxes = [source, target]
16
+ physical = boxes.map { |box| outline(box) }
17
+ clipped = physical_intersections(origins, physical)
18
+ selected = nearer_origin(clipped, origins)
19
+ orthogonal_pairs(clipped, selected, boxes, physical, origins) unless selected.nil?
20
+ end
21
+
22
+ def eligible?(connector, source, target)
23
+ connector.style.to_h[:orthogonal_origin_ray] == true && source && target &&
24
+ connector.source_anchor && connector.target_anchor
25
+ end
26
+ private_class_method :eligible?
27
+
28
+ def physical_intersections(origins, physical)
29
+ origins.each_with_index.map do |origin, index|
30
+ ConnectorRedock.anchor_exit(origin, physical[index], origins[1 - index])
31
+ end
32
+ end
33
+ private_class_method :physical_intersections
34
+
35
+ def outline(box)
36
+ Ea::Model::Bounds.new(x: box.x - 1, y: box.y - 1,
37
+ width: box.width + 2, height: box.height + 2)
38
+ end
39
+ private_class_method :outline
40
+
41
+ def nearer_origin(clipped, origins)
42
+ distances = clipped.zip(origins).map do |(x, y), origin|
43
+ (x - origin.x)**2 + (y - origin.y)**2
44
+ end
45
+ distances.each_with_index.min.last unless distances.first == distances.last
46
+ end
47
+ private_class_method :nearer_origin
48
+
49
+ def orthogonal_pairs(clipped, selected, boxes, physical, origins)
50
+ point = clipped[selected]
51
+ projected = project_opposite(point, physical[selected], boxes[1 - selected])
52
+ return unless projected && forward_projection?(point, projected, origins, selected)
53
+
54
+ selected.zero? ? [point, projected] : [projected, point]
55
+ end
56
+ private_class_method :orthogonal_pairs
57
+
58
+ def project_opposite(point, own, other)
59
+ case physical_edge_axis(point, own)
60
+ when :horizontal then horizontal_projection(point, other)
61
+ when :vertical then vertical_projection(point, other)
62
+ end
63
+ end
64
+ private_class_method :project_opposite
65
+
66
+ def physical_edge_axis(point, own)
67
+ horizontal = [own.y, own.y + own.height].include?(point[1])
68
+ vertical = [own.x, own.x + own.width].include?(point[0])
69
+ return unless horizontal ^ vertical
70
+
71
+ horizontal ? :horizontal : :vertical
72
+ end
73
+ private_class_method :physical_edge_axis
74
+
75
+ def horizontal_projection(point, other)
76
+ x, y = point
77
+ return unless x.between?(other.x, other.x + other.width)
78
+
79
+ edge = facing_edge(y, other.y, other.height)
80
+ [x, edge] if edge
81
+ end
82
+ private_class_method :horizontal_projection
83
+
84
+ def vertical_projection(point, other)
85
+ x, y = point
86
+ return unless y.between?(other.y, other.y + other.height)
87
+
88
+ edge = facing_edge(x, other.x, other.width)
89
+ [edge, y] if edge
90
+ end
91
+ private_class_method :vertical_projection
92
+
93
+ def facing_edge(coordinate, start, extent)
94
+ return start if coordinate < start
95
+ return start + extent if coordinate > start + extent
96
+
97
+ nil
98
+ end
99
+ private_class_method :facing_edge
100
+
101
+ def forward_projection?(point, projected, origins, selected)
102
+ first, last = origins.values_at(selected, 1 - selected)
103
+ x, y = point
104
+ target_x, target_y = projected
105
+ ((last.x - first.x) * (target_x - x) + (last.y - first.y) * (target_y - y)).positive?
106
+ end
107
+ private_class_method :forward_projection?
108
+ end
109
+ end
110
+ end
111
+ end
@@ -45,6 +45,9 @@ module Ea
45
45
  autoload :Document, "ea/svg/ea_emitter/document"
46
46
  autoload :Label, "ea/svg/ea_emitter/label"
47
47
  autoload :Compartment, "ea/svg/ea_emitter/compartment"
48
+ autoload :OrthogonalRay, "ea/svg/ea_emitter/orthogonal_ray"
49
+ private_constant :OrthogonalRay
50
+
48
51
  autoload :ConnectorRedock, "ea/svg/ea_emitter/connector_redock"
49
52
  autoload :RenderContext, "ea/svg/ea_emitter/render_context"
50
53
  autoload :VisibilitySymbol, "ea/svg/ea_emitter/visibility_symbol"
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.104"
7
+ VERSION = "0.6.106"
8
8
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ea
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.104
4
+ version: 0.6.106
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-15 00:00:00.000000000 Z
11
+ date: 2026-10-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: lutaml-model
@@ -950,6 +950,7 @@ files:
950
950
  - lib/ea/svg/ea_emitter/marker/plus.rb
951
951
  - lib/ea/svg/ea_emitter/marker/registry.rb
952
952
  - lib/ea/svg/ea_emitter/markers.rb
953
+ - lib/ea/svg/ea_emitter/orthogonal_ray.rb
953
954
  - lib/ea/svg/ea_emitter/render_context.rb
954
955
  - lib/ea/svg/ea_emitter/style.rb
955
956
  - lib/ea/svg/ea_emitter/text_renderer.rb