elkrb 1.0.0

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.
Files changed (89) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +11 -0
  4. data/Gemfile +13 -0
  5. data/README.adoc +1028 -0
  6. data/Rakefile +64 -0
  7. data/benchmarks/README.md +172 -0
  8. data/benchmarks/elkjs_benchmark.js +140 -0
  9. data/benchmarks/elkrb_benchmark.rb +145 -0
  10. data/benchmarks/fixtures/graphs.json +10777 -0
  11. data/benchmarks/generate_report.rb +241 -0
  12. data/benchmarks/generate_test_graphs.rb +154 -0
  13. data/benchmarks/results/elkrb_results.json +280 -0
  14. data/benchmarks/results/elkrb_summary.json +285 -0
  15. data/elkrb.gemspec +39 -0
  16. data/examples/dot_export_demo.rb +133 -0
  17. data/examples/hierarchical_graph.rb +19 -0
  18. data/examples/layout_constraints_demo.rb +272 -0
  19. data/examples/port_constraints_demo.rb +291 -0
  20. data/examples/self_loop_demo.rb +391 -0
  21. data/examples/simple_graph.rb +50 -0
  22. data/examples/spline_routing_demo.rb +235 -0
  23. data/exe/elkrb +8 -0
  24. data/lib/elkrb/cli.rb +224 -0
  25. data/lib/elkrb/commands/batch_command.rb +66 -0
  26. data/lib/elkrb/commands/convert_command.rb +130 -0
  27. data/lib/elkrb/commands/diagram_command.rb +208 -0
  28. data/lib/elkrb/commands/render_command.rb +52 -0
  29. data/lib/elkrb/commands/validate_command.rb +241 -0
  30. data/lib/elkrb/errors.rb +30 -0
  31. data/lib/elkrb/geometry/bezier.rb +163 -0
  32. data/lib/elkrb/geometry/dimension.rb +32 -0
  33. data/lib/elkrb/geometry/point.rb +68 -0
  34. data/lib/elkrb/geometry/rectangle.rb +86 -0
  35. data/lib/elkrb/geometry/vector.rb +67 -0
  36. data/lib/elkrb/graph/edge.rb +95 -0
  37. data/lib/elkrb/graph/graph.rb +90 -0
  38. data/lib/elkrb/graph/label.rb +45 -0
  39. data/lib/elkrb/graph/layout_options.rb +247 -0
  40. data/lib/elkrb/graph/node.rb +79 -0
  41. data/lib/elkrb/graph/node_constraints.rb +107 -0
  42. data/lib/elkrb/graph/port.rb +104 -0
  43. data/lib/elkrb/graphviz_wrapper.rb +133 -0
  44. data/lib/elkrb/layout/algorithm_registry.rb +57 -0
  45. data/lib/elkrb/layout/algorithms/base_algorithm.rb +208 -0
  46. data/lib/elkrb/layout/algorithms/box.rb +47 -0
  47. data/lib/elkrb/layout/algorithms/disco.rb +206 -0
  48. data/lib/elkrb/layout/algorithms/fixed.rb +32 -0
  49. data/lib/elkrb/layout/algorithms/force.rb +165 -0
  50. data/lib/elkrb/layout/algorithms/layered/cycle_breaker.rb +86 -0
  51. data/lib/elkrb/layout/algorithms/layered/layer_assigner.rb +96 -0
  52. data/lib/elkrb/layout/algorithms/layered/node_placer.rb +77 -0
  53. data/lib/elkrb/layout/algorithms/layered.rb +49 -0
  54. data/lib/elkrb/layout/algorithms/libavoid.rb +389 -0
  55. data/lib/elkrb/layout/algorithms/mrtree.rb +144 -0
  56. data/lib/elkrb/layout/algorithms/radial.rb +64 -0
  57. data/lib/elkrb/layout/algorithms/random.rb +43 -0
  58. data/lib/elkrb/layout/algorithms/rectpacking.rb +93 -0
  59. data/lib/elkrb/layout/algorithms/spore_compaction.rb +139 -0
  60. data/lib/elkrb/layout/algorithms/spore_overlap.rb +117 -0
  61. data/lib/elkrb/layout/algorithms/stress.rb +176 -0
  62. data/lib/elkrb/layout/algorithms/topdown_packing.rb +183 -0
  63. data/lib/elkrb/layout/algorithms/vertiflex.rb +174 -0
  64. data/lib/elkrb/layout/constraints/alignment_constraint.rb +150 -0
  65. data/lib/elkrb/layout/constraints/base_constraint.rb +72 -0
  66. data/lib/elkrb/layout/constraints/constraint_processor.rb +134 -0
  67. data/lib/elkrb/layout/constraints/fixed_position_constraint.rb +87 -0
  68. data/lib/elkrb/layout/constraints/layer_constraint.rb +71 -0
  69. data/lib/elkrb/layout/constraints/relative_position_constraint.rb +110 -0
  70. data/lib/elkrb/layout/edge_router.rb +935 -0
  71. data/lib/elkrb/layout/hierarchical_processor.rb +299 -0
  72. data/lib/elkrb/layout/label_placer.rb +338 -0
  73. data/lib/elkrb/layout/layout_engine.rb +170 -0
  74. data/lib/elkrb/layout/port_constraint_processor.rb +173 -0
  75. data/lib/elkrb/options/elk_padding.rb +94 -0
  76. data/lib/elkrb/options/k_vector.rb +100 -0
  77. data/lib/elkrb/options/k_vector_chain.rb +135 -0
  78. data/lib/elkrb/parsers/elkt_parser.rb +248 -0
  79. data/lib/elkrb/serializers/dot_serializer.rb +339 -0
  80. data/lib/elkrb/serializers/elkt_serializer.rb +236 -0
  81. data/lib/elkrb/version.rb +5 -0
  82. data/lib/elkrb.rb +509 -0
  83. data/sig/elkrb/constraints.rbs +114 -0
  84. data/sig/elkrb/geometry.rbs +61 -0
  85. data/sig/elkrb/graph.rbs +112 -0
  86. data/sig/elkrb/layout.rbs +107 -0
  87. data/sig/elkrb/options.rbs +81 -0
  88. data/sig/elkrb.rbs +32 -0
  89. metadata +179 -0
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "algorithm_registry"
4
+
5
+ module Elkrb
6
+ module Layout
7
+ # Main entry point for graph layout operations.
8
+ #
9
+ # The LayoutEngine provides a high-level interface for applying layout
10
+ # algorithms to graphs. It handles algorithm selection, graph conversion,
11
+ # and delegates to the appropriate algorithm implementation.
12
+ #
13
+ # @example Basic usage with hash input
14
+ # graph = {
15
+ # id: "root",
16
+ # layoutOptions: { "elk.algorithm" => "layered" },
17
+ # children: [
18
+ # { id: "n1", width: 100, height: 60 },
19
+ # { id: "n2", width: 100, height: 60 }
20
+ # ],
21
+ # edges: [
22
+ # { id: "e1", sources: ["n1"], targets: ["n2"] }
23
+ # ]
24
+ # }
25
+ # result = Elkrb::Layout::LayoutEngine.layout(graph)
26
+ #
27
+ # @example Using Graph model objects
28
+ # graph = Elkrb::Graph::Graph.new(id: "root")
29
+ # node1 = Elkrb::Graph::Node.new(id: "n1", width: 100, height: 60)
30
+ # node2 = Elkrb::Graph::Node.new(id: "n2", width: 100, height: 60)
31
+ # graph.children = [node1, node2]
32
+ # result = Elkrb::Layout::LayoutEngine.layout(graph, algorithm: "force")
33
+ #
34
+ # @example Querying available algorithms
35
+ # algorithms = Elkrb::Layout::LayoutEngine.known_layout_algorithms
36
+ # algorithms.each do |name, info|
37
+ # puts "#{name}: #{info[:description]}"
38
+ # end
39
+ class LayoutEngine
40
+ class << self
41
+ # Applies a layout algorithm to a graph.
42
+ #
43
+ # This method is the primary entry point for graph layout. It accepts
44
+ # either a Hash representation or a Graph model object, selects the
45
+ # appropriate algorithm based on options, and computes node positions
46
+ # and edge routes.
47
+ #
48
+ # The algorithm is selected in this order:
49
+ # 1. options[:algorithm] or options["algorithm"]
50
+ # 2. graph.layoutOptions["elk.algorithm"]
51
+ # 3. Default: "layered"
52
+ #
53
+ # @param graph [Hash, Elkrb::Graph::Graph] The graph to layout. Can be:
54
+ # - A Hash with keys: :id, :children, :edges, :layoutOptions
55
+ # - A Graph model object
56
+ # @param options [Hash] Layout options including:
57
+ # - :algorithm (String) - Algorithm name (layered, force, etc.)
58
+ # - Algorithm-specific options
59
+ # @return [Elkrb::Graph::Graph] The input graph with computed positions
60
+ # @raise [Elkrb::Error] If the specified algorithm is not found
61
+ #
62
+ # @example With specific algorithm
63
+ # result = Elkrb::Layout::LayoutEngine.layout(
64
+ # graph,
65
+ # algorithm: "force",
66
+ # "elk.force.repulsion" => 5.0
67
+ # )
68
+ #
69
+ # @example With hierarchical layout
70
+ # result = Elkrb::Layout::LayoutEngine.layout(
71
+ # graph,
72
+ # algorithm: "layered",
73
+ # hierarchical: true
74
+ # )
75
+ def layout(graph, options = {})
76
+ # Convert hash to Graph if needed
77
+ graph = convert_to_graph(graph) if graph.is_a?(Hash)
78
+
79
+ # Get algorithm name from options
80
+ algorithm_name = options[:algorithm] ||
81
+ options["algorithm"] ||
82
+ "layered"
83
+
84
+ algorithm_class = AlgorithmRegistry.get(algorithm_name)
85
+
86
+ raise Error, "Unknown layout algorithm: #{algorithm_name}" unless
87
+ algorithm_class
88
+
89
+ # Create and run algorithm with options
90
+ algorithm = algorithm_class.new(options)
91
+ algorithm.layout(graph)
92
+
93
+ graph
94
+ end
95
+
96
+ # Returns metadata for all registered layout algorithms.
97
+ #
98
+ # This method provides information about each available algorithm
99
+ # including its name, description, category, and capabilities.
100
+ #
101
+ # @return [Hash{String => Hash}] A hash mapping algorithm names to their metadata.
102
+ # Each metadata hash contains:
103
+ # - :name (String) - Display name of the algorithm
104
+ # - :description (String) - Brief description
105
+ # - :category (String, nil) - Algorithm category (e.g., "hierarchical", "force")
106
+ # - :supports_hierarchy (Boolean, nil) - Whether it supports hierarchical graphs
107
+ #
108
+ # @example List all algorithms
109
+ # algorithms = Elkrb::Layout::LayoutEngine.known_layout_algorithms
110
+ # algorithms.each do |name, info|
111
+ # puts "#{name}: #{info[:description]}"
112
+ # end
113
+ # # Output:
114
+ # # layered: Hierarchical layout using the Sugiyama framework
115
+ # # force: Physics-based layout using attractive and repulsive forces
116
+ # # ...
117
+ #
118
+ # @example Filter by category
119
+ # force_algs = Elkrb::Layout::LayoutEngine.known_layout_algorithms
120
+ # .select { |_, info| info[:category] == "force" }
121
+ def known_layout_algorithms
122
+ AlgorithmRegistry.all_algorithm_info
123
+ end
124
+
125
+ # Exports a graph to Graphviz DOT format.
126
+ #
127
+ # This method serializes an ELK graph structure to DOT format,
128
+ # which can be rendered by Graphviz or other DOT-compatible tools.
129
+ #
130
+ # @param graph [Hash, Elkrb::Graph::Graph] The graph to export
131
+ # @param options [Hash] Serialization options (see DotSerializer)
132
+ # @return [String] DOT format string
133
+ #
134
+ # @example Basic export
135
+ # dot = Elkrb::Layout::LayoutEngine.export_dot(graph)
136
+ # File.write("output.dot", dot)
137
+ #
138
+ # @example With custom options
139
+ # dot = Elkrb::Layout::LayoutEngine.export_dot(
140
+ # graph,
141
+ # directed: true,
142
+ # rankdir: "LR",
143
+ # graph_name: "MyGraph"
144
+ # )
145
+ def export_dot(graph, options = {})
146
+ # Convert hash to Graph if needed
147
+ graph = convert_to_graph(graph) if graph.is_a?(Hash)
148
+
149
+ serializer = Elkrb::Serializers::DotSerializer.new
150
+ serializer.serialize(graph, options)
151
+ end
152
+
153
+ # Returns metadata for all supported layout options.
154
+ #
155
+ # @return [Array<Hash>] Array of option metadata
156
+ # @note Currently returns an empty array. Full implementation planned.
157
+ def known_layout_options
158
+ # TODO: Build from all algorithms' supported options
159
+ []
160
+ end
161
+
162
+ private
163
+
164
+ def convert_to_graph(hash)
165
+ Graph::Graph.from_hash(hash)
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elkrb
4
+ module Layout
5
+ # Port Constraint Processor
6
+ #
7
+ # This module provides port constraint processing functionality for
8
+ # layout algorithms. It handles:
9
+ # - Automatic port side detection from positions
10
+ # - Port grouping by side
11
+ # - Port ordering within each side
12
+ # - Port positioning on node boundaries
13
+ module PortConstraintProcessor
14
+ # Apply port constraints to all nodes in the graph
15
+ #
16
+ # @param graph [Elkrb::Graph::Graph] The graph to process
17
+ def apply_port_constraints(graph)
18
+ return unless graph.children
19
+
20
+ graph.children.each do |node|
21
+ process_node_ports(node)
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ # Process ports for a single node
28
+ #
29
+ # @param node [Elkrb::Graph::Node] The node to process
30
+ def process_node_ports(node)
31
+ return unless node.ports && !node.ports.empty?
32
+ return unless node.width && node.height && node.width.positive? && node.height.positive?
33
+
34
+ # Detect sides if not specified
35
+ detect_port_sides(node)
36
+
37
+ # Group ports by side
38
+ ports_by_side = group_ports_by_side(node.ports)
39
+
40
+ # Apply ordering within each side
41
+ ports_by_side.each do |side, ports|
42
+ order_ports_on_side(node, side, ports)
43
+ end
44
+
45
+ # Position ports on node boundaries
46
+ position_ports_on_boundaries(node, ports_by_side)
47
+ end
48
+
49
+ # Detect port sides for ports with UNDEFINED side
50
+ #
51
+ # @param node [Elkrb::Graph::Node] The node containing the ports
52
+ def detect_port_sides(node)
53
+ node.ports.each do |port|
54
+ if port.side == Graph::Port::UNDEFINED
55
+ detected_side = port.detect_side(node.width, node.height)
56
+ port.side = detected_side
57
+ end
58
+ end
59
+ end
60
+
61
+ # Group ports by their side
62
+ #
63
+ # @param ports [Array<Elkrb::Graph::Port>] The ports to group
64
+ # @return [Hash<String, Array<Elkrb::Graph::Port>>] Ports grouped by side
65
+ def group_ports_by_side(ports)
66
+ ports.group_by(&:side)
67
+ end
68
+
69
+ # Order ports on a specific side
70
+ #
71
+ # Ports are sorted by:
72
+ # 1. Index (if specified and >= 0)
73
+ # 2. Position along the side (x for horizontal sides, y for vertical)
74
+ #
75
+ # @param node [Elkrb::Graph::Node] The node containing the ports
76
+ # @param side [String] The side to order ports on
77
+ # @param ports [Array<Elkrb::Graph::Port>] The ports on this side
78
+ def order_ports_on_side(_node, side, ports)
79
+ # Sort by index if specified, otherwise by position
80
+ ports.sort_by! do |port|
81
+ if port.index >= 0
82
+ port.index
83
+ elsif [Graph::Port::NORTH, Graph::Port::SOUTH].include?(side)
84
+ # Horizontal sides: sort by x position
85
+ port.x || 0
86
+ else
87
+ # Vertical sides: sort by y position
88
+ port.y || 0
89
+ end
90
+ end
91
+
92
+ # Assign sequential indices to ports without explicit index
93
+ ports.each_with_index do |port, idx|
94
+ port.index = idx if port.index.negative?
95
+ end
96
+ end
97
+
98
+ # Position ports on node boundaries
99
+ #
100
+ # @param node [Elkrb::Graph::Node] The node containing the ports
101
+ # @param ports_by_side [Hash<String, Array<Elkrb::Graph::Port>>] Ports grouped by side
102
+ def position_ports_on_boundaries(node, ports_by_side)
103
+ # NORTH: top edge, distributed horizontally
104
+ if ports_by_side[Graph::Port::NORTH]
105
+ distribute_ports_horizontally(
106
+ node,
107
+ ports_by_side[Graph::Port::NORTH],
108
+ 0,
109
+ )
110
+ end
111
+
112
+ # SOUTH: bottom edge, distributed horizontally
113
+ if ports_by_side[Graph::Port::SOUTH]
114
+ distribute_ports_horizontally(
115
+ node,
116
+ ports_by_side[Graph::Port::SOUTH],
117
+ node.height,
118
+ )
119
+ end
120
+
121
+ # WEST: left edge, distributed vertically
122
+ if ports_by_side[Graph::Port::WEST]
123
+ distribute_ports_vertically(
124
+ node,
125
+ ports_by_side[Graph::Port::WEST],
126
+ 0,
127
+ )
128
+ end
129
+
130
+ # EAST: right edge, distributed vertically
131
+ if ports_by_side[Graph::Port::EAST]
132
+ distribute_ports_vertically(
133
+ node,
134
+ ports_by_side[Graph::Port::EAST],
135
+ node.width,
136
+ )
137
+ end
138
+ end
139
+
140
+ # Distribute ports horizontally along a horizontal edge
141
+ #
142
+ # @param node [Elkrb::Graph::Node] The node containing the ports
143
+ # @param ports [Array<Elkrb::Graph::Port>] The ports to distribute
144
+ # @param y_pos [Float] The y position of the edge
145
+ def distribute_ports_horizontally(node, ports, y_pos)
146
+ count = ports.length
147
+ spacing = node.width / (count + 1).to_f
148
+
149
+ ports.each_with_index do |port, idx|
150
+ port.x = spacing * (idx + 1)
151
+ port.y = y_pos
152
+ port.offset = port.x
153
+ end
154
+ end
155
+
156
+ # Distribute ports vertically along a vertical edge
157
+ #
158
+ # @param node [Elkrb::Graph::Node] The node containing the ports
159
+ # @param ports [Array<Elkrb::Graph::Port>] The ports to distribute
160
+ # @param x_pos [Float] The x position of the edge
161
+ def distribute_ports_vertically(node, ports, x_pos)
162
+ count = ports.length
163
+ spacing = node.height / (count + 1).to_f
164
+
165
+ ports.each_with_index do |port, idx|
166
+ port.x = x_pos
167
+ port.y = spacing * (idx + 1)
168
+ port.offset = port.y
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elkrb
4
+ module Options
5
+ # ElkPadding parser for padding specifications
6
+ #
7
+ # Parses padding strings in the format:
8
+ # "[left=2, top=3, right=3, bottom=2]"
9
+ class ElkPadding
10
+ attr_reader :left, :top, :right, :bottom
11
+
12
+ def initialize(left: 0, top: 0, right: 0, bottom: 0)
13
+ @left = left.to_f
14
+ @top = top.to_f
15
+ @right = right.to_f
16
+ @bottom = bottom.to_f
17
+ end
18
+
19
+ # Parse padding from string or hash
20
+ #
21
+ # @param value [String, Hash, ElkPadding] The padding specification
22
+ # @return [ElkPadding] Parsed padding object
23
+ def self.parse(value)
24
+ return value if value.is_a?(ElkPadding)
25
+ return from_hash(value) if value.is_a?(Hash)
26
+ return from_string(value) if value.is_a?(String)
27
+
28
+ raise ArgumentError, "Invalid padding value: #{value.inspect}"
29
+ end
30
+
31
+ # Parse from hash
32
+ #
33
+ # @param hash [Hash] Hash with :left, :top, :right, :bottom keys
34
+ # @return [ElkPadding] Parsed padding object
35
+ def self.from_hash(hash)
36
+ new(
37
+ left: hash[:left] || hash["left"] || 0,
38
+ top: hash[:top] || hash["top"] || 0,
39
+ right: hash[:right] || hash["right"] || 0,
40
+ bottom: hash[:bottom] || hash["bottom"] || 0,
41
+ )
42
+ end
43
+
44
+ # Parse from string
45
+ #
46
+ # @param str [String] String like "[left=2, top=3, right=3, bottom=2]"
47
+ # @return [ElkPadding] Parsed padding object
48
+ def self.from_string(str)
49
+ # Remove brackets and split by comma
50
+ content = str.strip.gsub(/^\[|\]$/, "")
51
+ parts = {}
52
+
53
+ content.split(",").each do |part|
54
+ key, value = part.split("=").map(&:strip)
55
+ parts[key.to_sym] = value.to_f
56
+ end
57
+
58
+ new(**parts)
59
+ end
60
+
61
+ # Convert to hash
62
+ #
63
+ # @return [Hash] Hash representation
64
+ def to_h
65
+ {
66
+ left: @left,
67
+ top: @top,
68
+ right: @right,
69
+ bottom: @bottom,
70
+ }
71
+ end
72
+
73
+ # Convert to string
74
+ #
75
+ # @return [String] String representation
76
+ def to_s
77
+ "[left=#{@left}, top=#{@top}, right=#{@right}, bottom=#{@bottom}]"
78
+ end
79
+
80
+ # Check equality
81
+ #
82
+ # @param other [ElkPadding] Other padding object
83
+ # @return [Boolean] True if equal
84
+ def ==(other)
85
+ return false unless other.is_a?(ElkPadding)
86
+
87
+ @left == other.left &&
88
+ @top == other.top &&
89
+ @right == other.right &&
90
+ @bottom == other.bottom
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elkrb
4
+ module Options
5
+ # KVector parser for coordinate pairs
6
+ #
7
+ # Parses coordinate strings in the format:
8
+ # "(23, 43)" or "(23,43)"
9
+ class KVector
10
+ attr_reader :x, :y
11
+
12
+ def initialize(x, y)
13
+ @x = x.to_f
14
+ @y = y.to_f
15
+ end
16
+
17
+ # Parse KVector from string, hash, or array
18
+ #
19
+ # @param value [String, Hash, Array, KVector] The coordinate specification
20
+ # @return [KVector] Parsed coordinate object
21
+ def self.parse(value)
22
+ return value if value.is_a?(KVector)
23
+ return from_hash(value) if value.is_a?(Hash)
24
+ return from_array(value) if value.is_a?(Array)
25
+ return from_string(value) if value.is_a?(String)
26
+
27
+ raise ArgumentError, "Invalid KVector value: #{value.inspect}"
28
+ end
29
+
30
+ # Parse from hash
31
+ #
32
+ # @param hash [Hash] Hash with :x and :y keys
33
+ # @return [KVector] Parsed coordinate object
34
+ def self.from_hash(hash)
35
+ new(
36
+ hash[:x] || hash["x"] || 0,
37
+ hash[:y] || hash["y"] || 0,
38
+ )
39
+ end
40
+
41
+ # Parse from array
42
+ #
43
+ # @param array [Array] Array with [x, y] values
44
+ # @return [KVector] Parsed coordinate object
45
+ def self.from_array(array)
46
+ raise ArgumentError, "Array must have 2 elements" unless array.size == 2
47
+
48
+ new(array[0], array[1])
49
+ end
50
+
51
+ # Parse from string
52
+ #
53
+ # @param str [String] String like "(23, 43)" or "(23,43)"
54
+ # @return [KVector] Parsed coordinate object
55
+ def self.from_string(str)
56
+ # Remove parentheses and split by comma
57
+ content = str.strip.gsub(/^\(|\)$/, "")
58
+ parts = content.split(",").map(&:strip)
59
+
60
+ unless parts.size == 2
61
+ raise ArgumentError,
62
+ "Invalid KVector format: #{str}"
63
+ end
64
+
65
+ new(parts[0], parts[1])
66
+ end
67
+
68
+ # Convert to hash
69
+ #
70
+ # @return [Hash] Hash representation
71
+ def to_h
72
+ { x: @x, y: @y }
73
+ end
74
+
75
+ # Convert to array
76
+ #
77
+ # @return [Array] Array representation
78
+ def to_a
79
+ [@x, @y]
80
+ end
81
+
82
+ # Convert to string
83
+ #
84
+ # @return [String] String representation
85
+ def to_s
86
+ "(#{@x}, #{@y})"
87
+ end
88
+
89
+ # Check equality
90
+ #
91
+ # @param other [KVector] Other coordinate object
92
+ # @return [Boolean] True if equal
93
+ def ==(other)
94
+ return false unless other.is_a?(KVector)
95
+
96
+ @x == other.x && @y == other.y
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "k_vector"
4
+
5
+ module Elkrb
6
+ module Options
7
+ # KVectorChain parser for coordinate chains
8
+ #
9
+ # Parses coordinate chain strings in the format:
10
+ # "( {1,2}, {3,4} )" or "({1,2},{3,4})"
11
+ class KVectorChain
12
+ attr_reader :vectors
13
+
14
+ def initialize(vectors = [])
15
+ @vectors = vectors.map { |v| KVector.parse(v) }
16
+ end
17
+
18
+ # Parse KVectorChain from string or array
19
+ #
20
+ # @param value [String, Array, KVectorChain] The coordinate chain
21
+ # @return [KVectorChain] Parsed coordinate chain object
22
+ def self.parse(value)
23
+ return value if value.is_a?(KVectorChain)
24
+ return from_array(value) if value.is_a?(Array)
25
+ return from_string(value) if value.is_a?(String)
26
+
27
+ raise ArgumentError, "Invalid KVectorChain value: #{value.inspect}"
28
+ end
29
+
30
+ # Parse from array
31
+ #
32
+ # @param array [Array] Array of coordinate pairs or KVectors
33
+ # @return [KVectorChain] Parsed coordinate chain object
34
+ def self.from_array(array)
35
+ new(array)
36
+ end
37
+
38
+ # Parse from string
39
+ #
40
+ # @param str [String] String like "( {1,2}, {3,4} )"
41
+ # @return [KVectorChain] Parsed coordinate chain object
42
+ def self.from_string(str)
43
+ # Remove outer parentheses
44
+ content = str.strip.gsub(/^\(\s*|\s*\)$/, "")
45
+
46
+ # Split by },{ or } , {
47
+ parts = content.split(/\}\s*,\s*\{/)
48
+
49
+ # Clean up first and last parts
50
+ parts[0] = parts[0].sub(/^\{/, "") if parts[0]
51
+ parts[-1] = parts[-1].sub(/\}$/, "") if parts[-1]
52
+
53
+ # Parse each coordinate pair
54
+ vectors = parts.map do |part|
55
+ coords = part.split(",").map(&:strip)
56
+ unless coords.size == 2
57
+ raise ArgumentError,
58
+ "Invalid coordinate pair: #{part}"
59
+ end
60
+
61
+ KVector.new(coords[0], coords[1])
62
+ end
63
+
64
+ new(vectors)
65
+ end
66
+
67
+ # Add a vector to the chain
68
+ #
69
+ # @param vector [KVector, Array, Hash] Vector to add
70
+ # @return [KVectorChain] Self for chaining
71
+ def add(vector)
72
+ @vectors << KVector.parse(vector)
73
+ self
74
+ end
75
+ alias << add
76
+
77
+ # Get vector at index
78
+ #
79
+ # @param index [Integer] Index of vector
80
+ # @return [KVector] Vector at index
81
+ def [](index)
82
+ @vectors[index]
83
+ end
84
+
85
+ # Number of vectors in chain
86
+ #
87
+ # @return [Integer] Count of vectors
88
+ def size
89
+ @vectors.size
90
+ end
91
+ alias length size
92
+
93
+ # Check if chain is empty
94
+ #
95
+ # @return [Boolean] True if empty
96
+ def empty?
97
+ @vectors.empty?
98
+ end
99
+
100
+ # Iterate over vectors
101
+ #
102
+ # @yield [KVector] Each vector in chain
103
+ def each(&)
104
+ @vectors.each(&)
105
+ end
106
+
107
+ # Convert to array
108
+ #
109
+ # @return [Array<KVector>] Array of vectors
110
+ def to_a
111
+ @vectors.dup
112
+ end
113
+
114
+ # Convert to string
115
+ #
116
+ # @return [String] String representation
117
+ def to_s
118
+ return "()" if @vectors.empty?
119
+
120
+ vector_strs = @vectors.map { |v| "{#{v.x}, #{v.y}}" }
121
+ "( #{vector_strs.join(', ')} )"
122
+ end
123
+
124
+ # Check equality
125
+ #
126
+ # @param other [KVectorChain] Other coordinate chain
127
+ # @return [Boolean] True if equal
128
+ def ==(other)
129
+ return false unless other.is_a?(KVectorChain)
130
+
131
+ @vectors == other.vectors
132
+ end
133
+ end
134
+ end
135
+ end