ea 0.6.105 → 0.6.107

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: 430568b3607d439af39bac818b48ee27caa207875c7f2015e3a6f5689419680b
4
- data.tar.gz: dfb835e2208d50a51caa01acb96a30e2561f5ccbdf6e83a6a1c55ec368c4b5b8
3
+ metadata.gz: 931597d4cff3d8f491f63e12f3ed1a5ba31f8cc68d535b060222ca704388bda7
4
+ data.tar.gz: 70054ffff1db544c415879495386b4b45c7a1dafb88dfcc7d680325f37e4d019
5
5
  SHA512:
6
- metadata.gz: ce35bed8f6b13d1f799d8c99b550ad70d19453e2f2827888c1dce54b246dc9a938baa52cfe2d24f9fb9f093ecce1a86b52c4dbecbb60b397777c592cc61ea654
7
- data.tar.gz: 22747dc14774fbfaa984efba1c88078619d8ab6bc74b4d06d5276074a93957dccc071963574aad3ff272377d043e51e613723708d0fa48318acf6558ec920de1
6
+ metadata.gz: 8881133d7a2443adc4dcde58dd1290b532cea8c575f22b274dc818985d6540e22d11927f0aada28a5b27f5e76748be1fd2874bbde30018c09e3dd6422a3f3263
7
+ data.tar.gz: c91711384dc7ebefa97bdff19923697a5eacf8f22c362dfe1ee737e8d281a3c08224d63d66d411ff77ac7edb503db1245f7d40b443c82c4468d09885fa9487f8
@@ -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? &&
@@ -123,6 +123,7 @@ module Ea
123
123
  def build_element(obj_row, diagram_row)
124
124
  @current_obj_row = obj_row
125
125
  style_hash = DiagramStyleParser.parse(obj_row.objectstyle)
126
+ style_hash[:stored_attribute_order] = true
126
127
  Ea::Model::DiagramElement.new(
127
128
  id: IdNormalizer.synthetic("de", diagram_row.diagram_id,
128
129
  obj_row.instance_id),
@@ -16,7 +16,8 @@ module Ea
16
16
 
17
17
  def build_all_for(owner_object)
18
18
  attrs = database.attributes_for_object(owner_object.ea_object_id) || []
19
- attrs.map { |row| build_one(row, owner_object) }
19
+ attrs.each_with_index.sort_by { |row, index| attribute_sort_key(row, index) }
20
+ .map { |row, _index| build_one(row, owner_object) }
20
21
  end
21
22
 
22
23
  def build_one(row, owner_object)
@@ -48,6 +49,15 @@ module Ea
48
49
 
49
50
  private
50
51
 
52
+ # Equal explicit Pos values use name order in EA's published diagrams.
53
+ # Missing positions retain source order before the explicit zero cohort.
54
+ def attribute_sort_key(row, index)
55
+ return [row.sort_position, 0, "", "", index] if row.pos.nil?
56
+
57
+ name = row.name.to_s
58
+ [row.sort_position, 1, name.downcase, name, index]
59
+ end
60
+
51
61
  # EA stores some bounds as literal text ("n"); those render
52
62
  # verbatim in attribute lines while computed-unbounded bounds
53
63
  # render as "*" (verified against EA-published SVGs).
@@ -11,26 +11,17 @@ module Ea
11
11
  # content and suppresses them when they would overflow the
12
12
  # stored box height (never truncates rows).
13
13
  module Constraints
14
- DEFAULT_TEXT_COLOR = "#000000"
15
-
16
14
  module_function
17
15
 
18
16
  def render(context)
19
17
  return nil unless context.constraints&.any?
20
- return nil unless context.geometry.tagged_value_first_y
21
-
22
- first_y = context.geometry.tagged_value_first_y
23
- line_h = context.size + 6
24
- last_y = first_y + (context.constraints.size - 1) * line_h
25
- return nil if last_y + 4 > context.bounds.y + context.bounds.height
18
+ return nil unless context.geometry.constraints_first_y
26
19
 
27
- Element::ConstraintRenderer.render(
28
- context.constraints,
29
- bounds: context.bounds,
30
- first_y: first_y,
31
- family: context.family, size: context.size,
32
- fill: DEFAULT_TEXT_COLOR
20
+ divider = Element::DividerRenderer.render(
21
+ context.bounds, y: context.geometry.constraints_divider_y,
22
+ stroke: context.stroke, stroke_width: context.stroke_width
33
23
  )
24
+ "#{divider}\n#{Element::ConstraintRenderer.render_compartment(context)}"
34
25
  end
35
26
  end
36
27
  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,6 +59,7 @@ 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
@@ -80,6 +84,20 @@ module Ea
80
84
  end
81
85
  private_class_method :auto_port_elbow_pairs
82
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
+
83
101
  # EDGE=1 explicitly seats an external source ray on the top edge;
84
102
  # its first contact can instead be the bottom when its origin is below.
85
103
  def stored_top_pairs(connector, source, pairs)
@@ -290,10 +308,9 @@ module Ea
290
308
  end
291
309
  private_class_method :forward_route?
292
310
 
293
- # Exit point of the anchor-to-anchor segment through a box's
294
- # outline: smallest positive t whose crossing lies on the
295
- # rectangle perimeter.
296
- 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)
297
314
  ax, ay = anchor.x.to_f, anchor.y.to_f
298
315
  ox, oy = other.x.to_f, other.y.to_f
299
316
  dx, dy = ox - ax, oy - ay
@@ -314,12 +331,25 @@ module Ea
314
331
  x >= bounds.x - 0.01 && x <= bounds.x + bounds.width + 0.01 &&
315
332
  y >= bounds.y - 0.01 && y <= bounds.y + bounds.height + 0.01
316
333
  end
317
- 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
318
339
  # EA floors the dock coordinates (F851A657: 297.75 -> 297,
319
340
  # 433.7 -> 433) - a rounded-up dock is a visible miss.
320
341
  best ? [best[1].floor, best[2].floor] : [ax.floor, ay.floor]
321
342
  end
322
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
+
323
353
  # EA never docks a horizontal connector line at the header
324
354
  # divider: endpoints landing on (box side edge, divider_y)
325
355
  # move down one row slot to divider_y + 13 - the first
@@ -87,7 +87,7 @@ module Ea
87
87
  # Pass a `lookup` (any callable returning a Classifier for
88
88
  # an id) to enable inherited-property namespace prefixing.
89
89
  def self.lines_for(classifier, lookup: nil,
90
- exclude_association_ids: nil)
90
+ exclude_association_ids: nil, stored_order: false)
91
91
  props = displayable_properties(classifier, exclude_association_ids)
92
92
  return [] unless props
93
93
 
@@ -95,19 +95,14 @@ module Ea
95
95
  if stereotypes.size == 1 && props.all? { |p| property_stereotype(p) }
96
96
  # EA renders ONE compartment stereotype header for a
97
97
  # uniformly-stereotyped attribute set (e.g. «Property»
98
- # above GML property rows) and lists rows
99
- # alphabetically - verified against EA-published SVGs.
98
+ # above GML property rows).
100
99
  header = "«#{canonical_stereotype(stereotypes.first)}»"
101
- [header] + props.sort_by { |p| p.name.to_s }.map do |prop|
100
+ [header] + ordered_properties(props, stored_order, case_sensitive: true).map do |prop|
102
101
  AttributeLineBuilder.new(prop, host: classifier,
103
102
  lookup: lookup).to_s
104
103
  end
105
104
  else
106
- # EA lists attribute rows alphabetically (case-
107
- # insensitive) — corpus-verified: 991 boxes strictly
108
- # alphabetical vs 24 strictly stored-Pos order.
109
- props.sort_by { |p| [p.name.to_s.downcase, p.name.to_s] }
110
- .flat_map do |prop|
105
+ ordered_properties(props, stored_order).flat_map do |prop|
111
106
  lines = []
112
107
  stereotype = property_stereotype(prop)
113
108
  lines << "«#{stereotype}»" if stereotype
@@ -117,6 +112,20 @@ module Ea
117
112
  end
118
113
  end
119
114
 
115
+ # QEA properties arrive in stored Pos order. Association ends retain
116
+ # their alphabetical fallback rather than using connector Pos values.
117
+ def self.ordered_properties(props, stored_order, case_sensitive: false)
118
+ alphabetical = lambda do |prop|
119
+ name = prop.name.to_s
120
+ case_sensitive ? [name] : [name.downcase, name]
121
+ end
122
+ return props.sort_by(&alphabetical) unless stored_order
123
+
124
+ own, ends = props.partition { |prop| !prop.association_id }
125
+ own + ends.sort_by(&alphabetical)
126
+ end
127
+ private_class_method :ordered_properties
128
+
120
129
  STEREOTYPE_DISPLAY = {
121
130
  "property" => "Property",
122
131
  "featuretype" => "FeatureType",
@@ -150,14 +150,30 @@ module Ea
150
150
  divider_y + size + (attr_first_offset || 7)
151
151
  end
152
152
 
153
- # Tagged values appear after attributes (or after ops if
154
- # present). EA does not emit a separate divider — the
155
- # italic "tags" header marks the compartment. Constraints
156
- # render ABOVE the attributes (constraints_first_y), not
157
- # here.
153
+ # OCL constraints follow the regular content with a divider and caption.
154
+ # Suppress the whole compartment when its last row cannot fit.
155
+ def constraints_first_y
156
+ return nil unless constraints_count.to_i.positive?
157
+
158
+ first_y = constraints_caption_y
159
+ first_y if first_y + constraints_count * (size + 5) + 4 <= bounds.y + bounds.height
160
+ end
161
+
162
+ def constraints_caption_y
163
+ (regular_content_bottom_y || raw_attr_first_y) + size + 13
164
+ end
165
+
166
+ def constraints_divider_y
167
+ constraints_first_y - size - 7 if constraints_first_y
168
+ end
169
+
170
+ def constraints_bottom_y
171
+ constraints_first_y + constraints_count * (size + 5) if constraints_first_y
172
+ end
173
+
174
+ # Tagged values follow the last visible content compartment.
158
175
  def tagged_value_first_y
159
- return nil unless tagged_values_count.to_i.positive? ||
160
- constraints_count.to_i.positive?
176
+ return nil unless tagged_values_count.to_i.positive?
161
177
 
162
178
  (content_bottom_y || raw_attr_first_y) + size + 5
163
179
  end
@@ -169,6 +185,10 @@ module Ea
169
185
  # the autosize gate must not treat the empty-compartment
170
186
  # anchor as content.
171
187
  def content_bottom_y
188
+ [regular_content_bottom_y, constraints_bottom_y].compact.max
189
+ end
190
+
191
+ def regular_content_bottom_y
172
192
  bottoms = []
173
193
  bottoms << attr_bottom_y if attr_lines_count.to_i.positive?
174
194
  bottoms << enum_literals_bottom_y if enum_literals_count.to_i.positive?
@@ -185,4 +205,4 @@ module Ea
185
205
  end
186
206
  end
187
207
  end
188
- end
208
+ end
@@ -29,6 +29,34 @@ module Ea
29
29
  %(<g style="#{Style::TEXT_GROUP}">\n#{text_blocks.join("\n")}\n</g>)
30
30
  end
31
31
 
32
+ def self.render_compartment(context)
33
+ rows = context.constraints.each_with_index.map do |constraint, idx|
34
+ compartment_row(constraint, context, idx)
35
+ end
36
+ %(<g style="#{Style::TEXT_GROUP}">\n#{([compartment_caption(context)] + rows).join("\n")}\n</g>)
37
+ end
38
+
39
+ def self.compartment_row(constraint, context, index)
40
+ build_text(context.bounds.x + 5,
41
+ context.geometry.constraints_first_y + (index + 1) * (context.size + 5),
42
+ "{#{constraint.name}}", context.family, context.size, context.text_fill)
43
+ end
44
+ private_class_method :compartment_row
45
+
46
+ def self.compartment_caption(context)
47
+ TextRenderer.new(content: "constraints", x: caption_x(context),
48
+ y: context.geometry.constraints_first_y, family: context.family,
49
+ size: context.size, fill: context.text_fill, style: "italic").to_svg
50
+ end
51
+ private_class_method :compartment_caption
52
+
53
+ def self.caption_x(context)
54
+ width = TextRenderer.estimate_width("constraints", context.size, nil,
55
+ family: context.family, style: "italic").round
56
+ context.bounds.x + ((context.bounds.width - width) / 2).floor
57
+ end
58
+ private_class_method :caption_x
59
+
32
60
  def self.build_text(x, y, content, family, size, fill)
33
61
  TextRenderer.new(content: content, x: x, y: y,
34
62
  family: family, size: size, fill: fill).to_svg
@@ -135,7 +135,8 @@ module Ea
135
135
  classifier.properties.to_a.any? { |pr| !pr.association_id }))
136
136
  Element::AttributeRenderer.lines_for(
137
137
  classifier, lookup: attribute_lookup,
138
- exclude_association_ids: drawn_association_ids
138
+ exclude_association_ids: drawn_association_ids,
139
+ stored_order: element.style&.fetch(:stored_attribute_order, false)
139
140
  )
140
141
  else
141
142
  []
@@ -4,27 +4,11 @@ module Ea
4
4
  module Svg
5
5
  module EaEmitter
6
6
  module Marker
7
- # Diamond marker at the whole end of an aggregation, plus a
8
- # navigability arrow apexing at the diamond's line-side tip.
9
- #
10
- # WHOLE-END SELECTION: EA flags the whole end either through
11
- # t_connector.DestAccess (1 shared / 2 composite -> whole at
12
- # the TARGET; corpus-verified 333EFCCF: PersonalRight_E is
13
- # the DestAccess=2 end and every diamond sits there) or
14
- # through the Connector_Type itself ("Aggregation"/
15
- # "Composition" -> whole at the SOURCE; basic.qea's
16
- # composition diagram). DestAccess wins when both appear.
17
- # Without relationship data the direction heuristic applies
18
- # ("Destination -> Source" -> whole at target).
19
- #
20
- # ARROW: dest-flagged aggregations draw a base-12 height-15
21
- # open arrow stacked on the diamond tip pointing into the
22
- # whole (333EFCCF: 6 diamonds + 6 path arrows). Type-inferred
23
- # source-side aggregations draw the diamond only (basic.qea:
24
- # 17 diamonds, 0 arrows).
7
+ # EA endpoint fields select the diamond end; direction selects
8
+ # the arrow end for target-owned aggregations.
25
9
  class Diamond < Kind
26
10
  def self.handles?(effective_type)
27
- effective_type == "Aggregation" || effective_type == "Composition"
11
+ %w[Aggregation Composition].include?(effective_type)
28
12
  end
29
13
 
30
14
  def self.specs_for(connector, source, target, before_target, after_source, relationship: nil)
@@ -36,10 +20,8 @@ module Ea
36
20
  base: whole_base)]
37
21
  return specs unless whole_at_target
38
22
 
39
- specs << Registry::Spec.new(shape: :arrow,
40
- anchor: whole_anchor,
41
- base: whole_base)
42
- specs
23
+ specs.concat(direction_specs(connector, source, target,
24
+ before_target, after_source))
43
25
  end
44
26
 
45
27
  def self.whole_at_target?(connector, relationship)
@@ -58,6 +40,18 @@ module Ea
58
40
  relationship &&
59
41
  %w[shared composite].include?(relationship.source_aggregation.to_s)
60
42
  end
43
+
44
+ def self.direction_specs(connector, source, target, before_target,
45
+ after_source)
46
+ if connector.direction == "Destination -> Source"
47
+ return [Registry::Spec.new(shape: :arrow, anchor: source,
48
+ base: after_source)]
49
+ end
50
+
51
+ [Registry::Spec.new(shape: :arrow, anchor: target,
52
+ base: before_target)]
53
+ end
54
+ private_class_method :direction_specs
61
55
  end
62
56
  end
63
57
  end
@@ -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.105"
7
+ VERSION = "0.6.107"
8
8
  end
@@ -0,0 +1,3 @@
1
+ # Focused Ruby deviations
2
+
3
+ - Issue 135 attribute ordering: RuboCop Metrics/MethodLength flags the 14-line real-QEA fixture builder, and Metrics/BlockLength flags its six-example public pipeline regression group. Keep the fixture together and the directly related controls in one group; splitting them would obscure the input collections and ordering contract.
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.105
4
+ version: 0.6.107
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
@@ -1011,6 +1012,7 @@ files:
1011
1012
  - lib/ea/xml_escape.rb
1012
1013
  - lib/tasks/fonts.rake
1013
1014
  - lib/tasks/svg_bench.rake
1015
+ - memory/deviations.md
1014
1016
  - reference-docs/enterprise-architect-appearance-dialogs/appearance-1-diagram-1-themes-list.png
1015
1017
  - reference-docs/enterprise-architect-appearance-dialogs/appearance-1-diagram-1-themes.png
1016
1018
  - reference-docs/enterprise-architect-appearance-dialogs/appearance-1-diagram-2-gradients.png