elkrb 1.0.2 → 2.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 (144) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.adoc +364 -0
  3. data/LICENSE +25 -0
  4. data/README.adoc +695 -557
  5. data/exe/elkrb +36 -2
  6. data/lib/elkrb/atomic_destination.rb +53 -0
  7. data/lib/elkrb/best_effort_write.rb +49 -0
  8. data/lib/elkrb/cli/layout_flag_options.rb +54 -0
  9. data/lib/elkrb/cli.rb +155 -82
  10. data/lib/elkrb/command_resolver.rb +186 -0
  11. data/lib/elkrb/commands/batch_command.rb +28 -5
  12. data/lib/elkrb/commands/convert_command.rb +22 -52
  13. data/lib/elkrb/commands/diagram_command.rb +234 -102
  14. data/lib/elkrb/commands/options_command.rb +87 -0
  15. data/lib/elkrb/commands/render_command.rb +6 -1
  16. data/lib/elkrb/commands/validate_command.rb +146 -91
  17. data/lib/elkrb/errors.rb +20 -1
  18. data/lib/elkrb/format_sniffer.rb +362 -0
  19. data/lib/elkrb/geometry/bezier.rb +14 -4
  20. data/lib/elkrb/geometry/point.rb +5 -6
  21. data/lib/elkrb/geometry/rectangle.rb +2 -2
  22. data/lib/elkrb/graph/deep_stringify_keys.rb +30 -0
  23. data/lib/elkrb/graph/edge.rb +89 -3
  24. data/lib/elkrb/graph/graph.rb +43 -15
  25. data/lib/elkrb/graph/label.rb +16 -2
  26. data/lib/elkrb/graph/node.rb +13 -2
  27. data/lib/elkrb/graph/node_constraints.rb +139 -8
  28. data/lib/elkrb/graph/normalize_option_keys.rb +39 -0
  29. data/lib/elkrb/graph/port.rb +29 -21
  30. data/lib/elkrb/graph/read_only_mapping.rb +21 -0
  31. data/lib/elkrb/graphviz_wrapper.rb +152 -44
  32. data/lib/elkrb/layout/algorithm_registry.rb +65 -8
  33. data/lib/elkrb/layout/algorithms/base_algorithm.rb +74 -74
  34. data/lib/elkrb/layout/algorithms/box.rb +47 -20
  35. data/lib/elkrb/layout/algorithms/disco.rb +150 -61
  36. data/lib/elkrb/layout/algorithms/fixed.rb +70 -10
  37. data/lib/elkrb/layout/algorithms/force.rb +145 -86
  38. data/lib/elkrb/layout/algorithms/layered/bk/aligner.rb +104 -0
  39. data/lib/elkrb/layout/algorithms/layered/bk/compactor.rb +87 -0
  40. data/lib/elkrb/layout/algorithms/layered/bk/view.rb +103 -0
  41. data/lib/elkrb/layout/algorithms/layered/bk_node_placer.rb +170 -0
  42. data/lib/elkrb/layout/algorithms/layered/components.rb +170 -0
  43. data/lib/elkrb/layout/algorithms/layered/crossing_minimizer.rb +306 -0
  44. data/lib/elkrb/layout/algorithms/layered/cycle_breaker.rb +118 -49
  45. data/lib/elkrb/layout/algorithms/layered/discovery_order.rb +75 -0
  46. data/lib/elkrb/layout/algorithms/layered/dummy_slot.rb +31 -0
  47. data/lib/elkrb/layout/algorithms/layered/layer_assigner.rb +188 -48
  48. data/lib/elkrb/layout/algorithms/layered/layer_constraints.rb +82 -0
  49. data/lib/elkrb/layout/algorithms/layered/layer_sweep.rb +195 -0
  50. data/lib/elkrb/layout/algorithms/layered/node_placer.rb +214 -36
  51. data/lib/elkrb/layout/algorithms/layered/orthogonal/gap_solver.rb +219 -0
  52. data/lib/elkrb/layout/algorithms/layered/orthogonal/hyper_edge_segment.rb +152 -0
  53. data/lib/elkrb/layout/algorithms/layered/orthogonal/junction_points.rb +58 -0
  54. data/lib/elkrb/layout/algorithms/layered/orthogonal/segment_order.rb +134 -0
  55. data/lib/elkrb/layout/algorithms/layered/orthogonal_router.rb +253 -0
  56. data/lib/elkrb/layout/algorithms/layered/port_order.rb +188 -0
  57. data/lib/elkrb/layout/algorithms/layered/port_spread.rb +42 -0
  58. data/lib/elkrb/layout/algorithms/layered.rb +548 -13
  59. data/lib/elkrb/layout/algorithms/libavoid.rb +951 -187
  60. data/lib/elkrb/layout/algorithms/mrtree.rb +585 -65
  61. data/lib/elkrb/layout/algorithms/radial.rb +188 -29
  62. data/lib/elkrb/layout/algorithms/random.rb +131 -17
  63. data/lib/elkrb/layout/algorithms/rectpacking.rb +28 -40
  64. data/lib/elkrb/layout/algorithms/spore_compaction.rb +57 -100
  65. data/lib/elkrb/layout/algorithms/spore_overlap.rb +78 -84
  66. data/lib/elkrb/layout/algorithms/stress.rb +391 -84
  67. data/lib/elkrb/layout/algorithms/topdown_packing.rb +95 -31
  68. data/lib/elkrb/layout/algorithms/vertiflex.rb +12 -47
  69. data/lib/elkrb/layout/constraints/alignment_constraint.rb +49 -12
  70. data/lib/elkrb/layout/constraints/base_constraint.rb +3 -15
  71. data/lib/elkrb/layout/constraints/constraint_processor.rb +9 -23
  72. data/lib/elkrb/layout/constraints/fixed_position_constraint.rb +22 -13
  73. data/lib/elkrb/layout/constraints/layer_constraint.rb +10 -33
  74. data/lib/elkrb/layout/constraints/relative_position_constraint.rb +94 -50
  75. data/lib/elkrb/layout/edge_router.rb +533 -337
  76. data/lib/elkrb/layout/hierarchical_processor.rb +19 -276
  77. data/lib/elkrb/layout/java_random.rb +59 -0
  78. data/lib/elkrb/layout/label_placer.rb +165 -157
  79. data/lib/elkrb/layout/layout_engine.rb +46 -23
  80. data/lib/elkrb/layout/node_index.rb +149 -0
  81. data/lib/elkrb/layout/polyomino/component_compactor.rb +138 -0
  82. data/lib/elkrb/layout/polyomino/footprint.rb +94 -0
  83. data/lib/elkrb/layout/polyomino/grid.rb +106 -0
  84. data/lib/elkrb/layout/polyomino/packer.rb +95 -0
  85. data/lib/elkrb/layout/polyomino/piece.rb +52 -0
  86. data/lib/elkrb/layout/port_constraint_processor.rb +131 -44
  87. data/lib/elkrb/layout/spore/body.rb +65 -0
  88. data/lib/elkrb/layout/spore/depth_first_compaction.rb +127 -0
  89. data/lib/elkrb/layout/spore/edge_cost.rb +69 -0
  90. data/lib/elkrb/layout/spore/gap.rb +76 -0
  91. data/lib/elkrb/layout/spore/geometry.rb +104 -0
  92. data/lib/elkrb/layout/spore/overlap_sweep.rb +90 -0
  93. data/lib/elkrb/layout/spore/placement.rb +57 -0
  94. data/lib/elkrb/layout/spore/rays.rb +119 -0
  95. data/lib/elkrb/layout/spore/spanning_tree.rb +53 -0
  96. data/lib/elkrb/layout/spore/triangulation.rb +159 -0
  97. data/lib/elkrb/layout_flags.rb +69 -0
  98. data/lib/elkrb/options/decimal.rb +50 -0
  99. data/lib/elkrb/options/element_options.rb +43 -0
  100. data/lib/elkrb/options/elk_padding.rb +20 -14
  101. data/lib/elkrb/options/k_vector.rb +6 -7
  102. data/lib/elkrb/options/k_vector_chain.rb +12 -20
  103. data/lib/elkrb/options/option_map.rb +85 -0
  104. data/lib/elkrb/options/registry.rb +335 -0
  105. data/lib/elkrb/options/resolver.rb +151 -0
  106. data/lib/elkrb/options/spellings.rb +62 -0
  107. data/lib/elkrb/options/unhonoured_report.rb +194 -0
  108. data/lib/elkrb/parsers/elkt/lexer.rb +309 -0
  109. data/lib/elkrb/parsers/elkt/parser.rb +424 -0
  110. data/lib/elkrb/parsers/elkt/resolver.rb +142 -0
  111. data/lib/elkrb/parsers/elkt/token.rb +40 -0
  112. data/lib/elkrb/parsers/elkt_parser.rb +32 -230
  113. data/lib/elkrb/serializers/dot_serializer.rb +342 -54
  114. data/lib/elkrb/serializers/elkt_serializer.rb +386 -155
  115. data/lib/elkrb/version.rb +1 -1
  116. data/lib/elkrb.rb +60 -61
  117. metadata +71 -49
  118. data/.rspec +0 -3
  119. data/.rubocop.yml +0 -11
  120. data/Gemfile +0 -13
  121. data/Rakefile +0 -64
  122. data/benchmarks/README.md +0 -172
  123. data/benchmarks/elkjs_benchmark.js +0 -140
  124. data/benchmarks/elkrb_benchmark.rb +0 -145
  125. data/benchmarks/fixtures/graphs.json +0 -10777
  126. data/benchmarks/generate_report.rb +0 -241
  127. data/benchmarks/generate_test_graphs.rb +0 -154
  128. data/benchmarks/results/elkrb_results.json +0 -280
  129. data/benchmarks/results/elkrb_summary.json +0 -285
  130. data/elkrb.gemspec +0 -39
  131. data/examples/dot_export_demo.rb +0 -133
  132. data/examples/hierarchical_graph.rb +0 -19
  133. data/examples/layout_constraints_demo.rb +0 -272
  134. data/examples/port_constraints_demo.rb +0 -291
  135. data/examples/self_loop_demo.rb +0 -391
  136. data/examples/simple_graph.rb +0 -50
  137. data/examples/spline_routing_demo.rb +0 -235
  138. data/lib/elkrb/graph/layout_options.rb +0 -247
  139. data/sig/elkrb/constraints.rbs +0 -114
  140. data/sig/elkrb/geometry.rbs +0 -61
  141. data/sig/elkrb/graph.rbs +0 -112
  142. data/sig/elkrb/layout.rbs +0 -107
  143. data/sig/elkrb/options.rbs +0 -81
  144. data/sig/elkrb.rbs +0 -32
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../geometry/point"
5
+
6
+ module Elkrb
7
+ module Layout
8
+ # Resolves node and port ids to their owning node, for ONE hierarchy
9
+ # level. Node ids and port ids share one namespace at a level; the
10
+ # same id may legitimately repeat at a different level (build a
11
+ # fresh index per level, never reused across `layout_flat` calls).
12
+ #
13
+ # `.build` is the entry point; construct every index through it.
14
+ class NodeIndex
15
+ # The edges of this level, in graph-then-children order.
16
+ attr_reader :edges
17
+
18
+ def self.build(graph)
19
+ new(graph)
20
+ end
21
+
22
+ # Indexes every hierarchy level while retaining each level's namespace.
23
+ # The offset is the sum of ancestor node coordinates; a node's own x/y
24
+ # remain local and are not included in its entry.
25
+ def self.build_deep(graph)
26
+ entries = {}
27
+ build_deep_level(graph, Geometry::Point.new, entries)
28
+ entries
29
+ end
30
+
31
+ def self.build_deep_level(graph, offset, entries)
32
+ index = build(graph)
33
+ children = graph.children || []
34
+ children.each { |node| record_deep_entries(entries, index, node, offset) }
35
+ build_deep_children(children, offset, entries)
36
+ end
37
+ private_class_method :build_deep_level
38
+
39
+ def self.build_deep_children(children, offset, entries)
40
+ children.select(&:hierarchical?).each do |node|
41
+ build_deep_level(node, descendant_offset(offset, node), entries)
42
+ end
43
+ end
44
+ private_class_method :build_deep_children
45
+
46
+ def self.descendant_offset(offset, node)
47
+ Geometry::Point.new(
48
+ x: offset.x + (node.x || 0.0),
49
+ y: offset.y + (node.y || 0.0),
50
+ )
51
+ end
52
+ private_class_method :descendant_offset
53
+
54
+ def self.record_deep_entries(entries, index, node, offset)
55
+ [node.id, *node.ports.to_a.map(&:id)].each do |id|
56
+ (entries[id] ||= []) << [index.node(id), offset]
57
+ end
58
+ end
59
+ private_class_method :record_deep_entries
60
+
61
+ def node(id)
62
+ @nodes_by_id[id]
63
+ end
64
+
65
+ def endpoint_nodes(ids)
66
+ (ids || []).filter_map { |id| node(id) }
67
+ end
68
+
69
+ # Endpoints resolved for TOPOLOGY: a nested endpoint projects onto its
70
+ # top-level ancestor instead of vanishing. Use this wherever an edge
71
+ # contributes to layering or cycle detection; use #endpoint_nodes when
72
+ # you want the literal nodes of this level and nothing else.
73
+ def endpoint_owners(ids)
74
+ (ids || []).filter_map { |id| owner(id) }.uniq
75
+ end
76
+
77
+ # The node AT THIS LEVEL that owns `id`: the node itself, the node
78
+ # whose port it is, or the top-level ancestor of a nested descendant.
79
+ #
80
+ # This level always wins, so a nested id can never shadow one here --
81
+ # that shadowing is what #node exists to keep out. But a graph-owned
82
+ # cross-hierarchy edge (`c1 -> p2`, with c1 nested under p1) is
83
+ # supported, and dropping its nested endpoint takes the edge out of
84
+ # the topology entirely. Projecting c1 onto p1 keeps it.
85
+ #
86
+ # Use this for graph-owned edges only. Child-owned nested edges stay
87
+ # excluded by #edges_on, which is a separate concern.
88
+ def owner(id)
89
+ node(id) || @owners_by_descendant_id[id]
90
+ end
91
+
92
+ # The edges `child` declares that belong to THIS level. A
93
+ # hierarchical child's `edges` address its own namespace --
94
+ # `HierarchicalProcessor#create_child_graph` hands them to the
95
+ # child's level, which is where their ids resolve. Ids are unique
96
+ # only within a level, so reading them here aliases a nested
97
+ # endpoint onto an unrelated node or port of this level.
98
+ def edges_on(child)
99
+ return [] if child.hierarchical?
100
+
101
+ child.edges || []
102
+ end
103
+
104
+ private
105
+
106
+ def initialize(graph)
107
+ @nodes_by_id = {}
108
+ children = graph.children || []
109
+ children.each { |child| index_node(child) }
110
+ @edges = ((graph.edges || []) +
111
+ children.flat_map { |child| edges_on(child) }).freeze
112
+ @owners_by_descendant_id = {}
113
+ children.each { |child| index_descendants(child, child) }
114
+ end
115
+
116
+ # Maps every id below a top-level child back to that child. Only ids
117
+ # this level does not already own are recorded, so #owner can never
118
+ # let a descendant displace a node or port of this level.
119
+ def index_descendants(node, owner)
120
+ (node.children || []).each do |descendant|
121
+ claim_descendant(descendant.id, owner)
122
+ descendant.ports&.each { |port| claim_descendant(port.id, owner) }
123
+ index_descendants(descendant, owner)
124
+ end
125
+ end
126
+
127
+ def claim_descendant(id, owner)
128
+ return if id.nil? || @nodes_by_id.key?(id) || @owners_by_descendant_id.key?(id)
129
+
130
+ @owners_by_descendant_id[id] = owner
131
+ end
132
+
133
+ def index_node(node)
134
+ add(node.id, node, "node")
135
+ node.ports&.each { |port| add(port.id, node, "port") }
136
+ end
137
+
138
+ def add(id, node, kind)
139
+ raise Elkrb::ValidationError, "#{kind} without id" if id.nil?
140
+
141
+ if @nodes_by_id.key?(id)
142
+ raise Elkrb::ValidationError, "duplicate id: #{id}"
143
+ end
144
+
145
+ @nodes_by_id[id] = node
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "footprint"
4
+ require_relative "packer"
5
+ require_relative "../../geometry/rectangle"
6
+ require_relative "../../geometry/dimension"
7
+
8
+ module Elkrb
9
+ module Layout
10
+ module Polyomino
11
+ # Packs disconnected components the way ELK's DisCoPolyominoCompactor
12
+ # does: each component becomes a low-resolution polyomino, the
13
+ # polyominoes are packed, and each component gets the offset that moves
14
+ # it to its polyomino's place.
15
+ #
16
+ # A component is an Array of shapes, a shape an Array of [x, y] corners.
17
+ class ComponentCompactor
18
+ # ELK's bound on the cell count per component that the cell size
19
+ # is solved for.
20
+ UPPER_BOUND = 100.0
21
+ private_constant :UPPER_BOUND
22
+
23
+ # @param components [Array<Array<Array<Array<Float>>>>]
24
+ # @param aspect_ratio [Float] target width/height, above zero
25
+ # @param fill [Boolean]
26
+ def initialize(components, aspect_ratio:, fill: true)
27
+ @components = components
28
+ @aspect_ratio = aspect_ratio
29
+ @fill = fill
30
+ end
31
+
32
+ # @return [Array<Array(Float, Float)>] the [dx, dy] to add to each
33
+ # component's coordinates, in the order the components were given
34
+ def offsets
35
+ return [] if @components.empty?
36
+
37
+ @boxes = @components.map { |shapes| bounding_box(shapes) }
38
+ @cell = cell_size
39
+ placed, origin = pack_pieces
40
+ placed.sort_by(&:payload).map { |piece| offset_of(piece, origin) }
41
+ end
42
+
43
+ private
44
+
45
+ def bounding_box(shapes)
46
+ xs, ys = shapes.flatten(1).transpose
47
+ Geometry::Rectangle.new(xs.min, ys.min,
48
+ xs.max - xs.min, ys.max - ys.min)
49
+ end
50
+
51
+ # The packed pieces and the [col, row] of the packing's top-left.
52
+ def pack_pieces
53
+ pieces = @components.each_index.map { |index| build_piece(index) }
54
+ placed, grid = Packer.pack(pieces, aspect_ratio: @aspect_ratio,
55
+ fill: @fill)
56
+ [placed, (grid.filled_bounds || [0, 0]).first(2)]
57
+ end
58
+
59
+ def offset_of(piece, origin)
60
+ box = @boxes[piece.payload]
61
+ [horizontal_offset(piece, box, origin[0]),
62
+ vertical_offset(piece, box, origin[1])]
63
+ end
64
+
65
+ def horizontal_offset(piece, box, origin_col)
66
+ axis_offset(piece.col - origin_col, piece.width, @cell.width,
67
+ box.x, box.width)
68
+ end
69
+
70
+ def vertical_offset(piece, box, origin_row)
71
+ axis_offset(piece.row - origin_row, piece.height, @cell.height,
72
+ box.y, box.height)
73
+ end
74
+
75
+ # Cells from the packing's edge to the piece, plus half the space the
76
+ # piece has over the component, less where the component starts.
77
+ def axis_offset(cells_from_edge, piece_cells, cell, start, size)
78
+ (cells_from_edge * cell) + margin(size, piece_cells, cell) - start
79
+ end
80
+
81
+ # The polyomino is at least as large as the component; half the
82
+ # difference is the space either side of it.
83
+ def margin(size, cells, cell)
84
+ ((cells * cell) - size) / 2.0
85
+ end
86
+
87
+ # The cell size at which packing every component with at most
88
+ # UPPER_BOUND cells of slack stays tight (ELK's computeCellSize),
89
+ # stretched by the aspect ratio.
90
+ def cell_size
91
+ base = solve_cell_size
92
+ if @aspect_ratio > 1.0
93
+ Geometry::Dimension.new(base * @aspect_ratio, base)
94
+ else
95
+ Geometry::Dimension.new(base, base / @aspect_ratio)
96
+ end
97
+ end
98
+
99
+ def solve_cell_size
100
+ size = cell_formula(@boxes.sum { |box| box.width + box.height },
101
+ @boxes.sum { |box| box.width * box.height },
102
+ UPPER_BOUND * @boxes.size)
103
+ size.positive? ? size : 1.0
104
+ end
105
+
106
+ def cell_formula(sum, product, cells)
107
+ root = Math.sqrt((4.0 * cells * product) - (4.0 * product) +
108
+ (sum * sum))
109
+ (root + sum) / (2.0 * (cells - 1.0))
110
+ end
111
+
112
+ def build_piece(index)
113
+ box = @boxes[index]
114
+ piece = Piece.new((box.width / @cell.width).ceil,
115
+ (box.height / @cell.height).ceil, index)
116
+ block_touched_cells(piece, first_footprint(box, piece),
117
+ @components[index])
118
+ piece
119
+ end
120
+
121
+ def first_footprint(box, piece)
122
+ Footprint.new(
123
+ box.x - margin(box.width, piece.width, @cell.width),
124
+ box.y - margin(box.height, piece.height, @cell.height),
125
+ @cell.width, @cell.height
126
+ )
127
+ end
128
+
129
+ def block_touched_cells(piece, first, shapes)
130
+ piece.positions.each do |col, row|
131
+ cell = first.step(col, row)
132
+ piece.block(col, row) if shapes.any? { |s| cell.touched_by?(s) }
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elkrb
4
+ module Layout
5
+ module Polyomino
6
+ # One raster cell, as a rectangle in the plane of the shapes it is
7
+ # tested against. A shape is an Array of [x, y] corners.
8
+ #
9
+ # ELK rasterises a shape to the cells whose border it crosses, or that
10
+ # contain it whole; a cell lying wholly inside a shape is not touched
11
+ # by it. Profile Fill closes that gap.
12
+ class Footprint
13
+ EPSILON = 0.00001
14
+ private_constant :EPSILON
15
+
16
+ def initialize(left, top, width, height)
17
+ @left = left
18
+ @top = top
19
+ @width = width
20
+ @height = height
21
+ end
22
+
23
+ # The footprint +cols+ cells right and +rows+ cells down.
24
+ def step(cols, rows)
25
+ self.class.new(@left + (cols * @width), @top + (rows * @height),
26
+ @width, @height)
27
+ end
28
+
29
+ # ELK's DCElement#intersects: a border of the cell crosses the shape,
30
+ # or the shape lies strictly inside the cell. Touching does not count.
31
+ def touched_by?(shape)
32
+ return false if shape.size < 2
33
+
34
+ crosses_border?(shape) || shape.all? { |point| inside?(point) }
35
+ end
36
+
37
+ private
38
+
39
+ def crosses_border?(shape)
40
+ shape.zip(shape.rotate).any? do |first, second|
41
+ !(inside?(first) && inside?(second)) &&
42
+ sides.any? { |side| crossing?(side, [first, second]) }
43
+ end
44
+ end
45
+
46
+ def inside?(point)
47
+ strictly_between?(point[0], @left, @left + @width) &&
48
+ strictly_between?(point[1], @top, @top + @height)
49
+ end
50
+
51
+ def strictly_between?(value, low, high)
52
+ low < value && value < high
53
+ end
54
+
55
+ def sides
56
+ right = @left + @width
57
+ bottom = @top + @height
58
+ corners = [[@left, @top], [right, @top],
59
+ [right, bottom], [@left, bottom]]
60
+ corners.zip(corners.rotate)
61
+ end
62
+
63
+ # ELK's ElkMath.intersects(l11, l12, l21, l22): the segments cross
64
+ # at a point interior to both, within EPSILON.
65
+ def crossing?(first, second)
66
+ direction = vector(first)
67
+ other = vector(second)
68
+ det = cross_product(other, direction)
69
+ return false if det.abs <= EPSILON
70
+
71
+ delta = difference(first[0], second[0])
72
+ [cross_product(delta, direction), cross_product(delta, other)]
73
+ .all? { |numerator| interior?(numerator / det) }
74
+ end
75
+
76
+ def interior?(fraction)
77
+ fraction > EPSILON && fraction < 1 - EPSILON
78
+ end
79
+
80
+ def vector(segment)
81
+ difference(segment[1], segment[0])
82
+ end
83
+
84
+ def difference(first, second)
85
+ [first[0] - second[0], first[1] - second[1]]
86
+ end
87
+
88
+ def cross_product(first, second)
89
+ (first[0] * second[1]) - (first[1] * second[0])
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elkrb
4
+ module Layout
5
+ module Polyomino
6
+ # A rectangular grid of blocked and empty cells, addressed either from
7
+ # the top-left corner or from the cell at (center_col, center_row).
8
+ #
9
+ # Port of the two-valued core of ELK's PlanarGrid. Out-of-range reads
10
+ # raise IndexError; Ruby arrays would otherwise wrap negative indexes
11
+ # around to the far edge.
12
+ class Grid
13
+ attr_reader :width, :height, :center_col, :center_row
14
+
15
+ def initialize(width, height)
16
+ @width = width
17
+ @height = height
18
+ @center_col = (width - 1) >> 1
19
+ @center_row = (height - 1) >> 1
20
+ @cells = Array.new(height) { Array.new(width, false) }
21
+ end
22
+
23
+ def blocked?(col, row)
24
+ check_bounds(col, row)
25
+ @cells[row][col]
26
+ end
27
+
28
+ def block(col, row)
29
+ check_bounds(col, row)
30
+ @cells[row][col] = true
31
+ end
32
+
33
+ def in_bounds?(col, row)
34
+ col.between?(0, width - 1) && row.between?(0, height - 1)
35
+ end
36
+
37
+ # Each blocked cell as (col, row), top row first.
38
+ def each_blocked
39
+ return enum_for(:each_blocked) unless block_given?
40
+
41
+ @cells.each_with_index do |line, row|
42
+ line.each_with_index { |cell, col| yield col, row if cell }
43
+ end
44
+ end
45
+
46
+ # Every (col, row) of the grid.
47
+ def positions
48
+ (0...width).to_a.product((0...height).to_a)
49
+ end
50
+
51
+ # The cells as rows of booleans, and as columns of booleans.
52
+ def rows
53
+ @cells.map(&:dup)
54
+ end
55
+
56
+ def columns
57
+ Array.new(width) { |col| @cells.map { |line| line[col] } }
58
+ end
59
+
60
+ # The [left, top] of +other+ on this grid when its center cell sits
61
+ # at (+shift_col+, +shift_row+) from this grid's center cell.
62
+ def origin_for(other, shift_col, shift_row)
63
+ [shift_col - other.center_col + center_col,
64
+ shift_row - other.center_row + center_row]
65
+ end
66
+
67
+ # True when placing +other+ at that shift would put a blocked cell of
68
+ # +other+ on a blocked cell of this grid or outside it.
69
+ def intersects?(other, shift_col, shift_row)
70
+ left, top = origin_for(other, shift_col, shift_row)
71
+ other.each_blocked.any? do |col, row|
72
+ taken?(left + col, top + row)
73
+ end
74
+ end
75
+
76
+ # Blocks the cells +other+ blocks when placed at that shift.
77
+ def add_blocked_cells_from(other, shift_col, shift_row)
78
+ left, top = origin_for(other, shift_col, shift_row)
79
+ other.each_blocked { |col, row| block(left + col, top + row) }
80
+ end
81
+
82
+ # The bounding box of the blocked cells as [min_col, min_row, width,
83
+ # height], or nil when nothing is blocked.
84
+ def filled_bounds
85
+ cols, rows = each_blocked.to_a.transpose
86
+ return nil unless cols
87
+
88
+ [cols.min, rows.min,
89
+ cols.max - cols.min + 1, rows.max - rows.min + 1]
90
+ end
91
+
92
+ private
93
+
94
+ def taken?(col, row)
95
+ !in_bounds?(col, row) || @cells[row][col]
96
+ end
97
+
98
+ def check_bounds(col, row)
99
+ return if in_bounds?(col, row)
100
+
101
+ raise IndexError, "(#{col}, #{row}) is outside #{width}x#{height}"
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "piece"
4
+
5
+ module Elkrb
6
+ module Layout
7
+ module Polyomino
8
+ # Packs pieces onto one grid, largest first, each at the first free
9
+ # position of a line-by-line walk out from the grid's center.
10
+ #
11
+ # Port of ELK's PolyominoCompactor with the defaults DisCo runs with:
12
+ # QUADRANTS_LINE_BY_LINE traversal, BY_SIZE_AND_SHAPE ordering, and
13
+ # Profile Fill. Pieces have no extensions, so ELK's extension-based
14
+ # orderings and quadrant restrictions are all no-ops and are not ported.
15
+ module Packer
16
+ module_function
17
+
18
+ # Places every piece and returns the pieces in packing order. Each
19
+ # piece's col and row become its top-left cell on the returned grid.
20
+ #
21
+ # @param pieces [Array<Piece>]
22
+ # @param aspect_ratio [Float] target width/height of the packing
23
+ # @param fill [Boolean] apply Profile Fill before packing
24
+ # @return [Array(Array<Piece>, Grid)] pieces in packing order, grid
25
+ def pack(pieces, aspect_ratio:, fill: true)
26
+ pieces.each(&:fill_enclosed_cells) if fill
27
+ grid = Grid.new(*grid_size(pieces, aspect_ratio))
28
+ ordered = packing_order(pieces)
29
+ ordered.each { |piece| place(grid, piece) }
30
+ [ordered, grid]
31
+ end
32
+
33
+ # Largest first; equal pieces keep the order they were given in.
34
+ def packing_order(pieces)
35
+ pieces.each_with_index
36
+ .sort_by { |piece, index| [-shape_cost(piece), index] }
37
+ .map(&:first)
38
+ end
39
+
40
+ def grid_size(pieces, aspect_ratio)
41
+ width = doubled_extent(pieces, :width)
42
+ height = doubled_extent(pieces, :height)
43
+ return [(width * aspect_ratio).ceil, height] if aspect_ratio > 1.0
44
+
45
+ [width, (height / aspect_ratio).ceil]
46
+ end
47
+
48
+ def doubled_extent(pieces, side)
49
+ first = pieces.first&.public_send(side) || 0
50
+ 2 * (pieces.sum(&side) + first)
51
+ end
52
+
53
+ # Squared short side plus long side: ELK's
54
+ # MinPerimeterComparatorWithShape.
55
+ def shape_cost(piece)
56
+ short, long = [piece.width, piece.height].minmax
57
+ (short * short) + long
58
+ end
59
+
60
+ def place(grid, piece)
61
+ shift = free_shift(grid, piece)
62
+ grid.add_blocked_cells_from(piece, *shift)
63
+ piece.col, piece.row = grid.origin_for(piece, *shift)
64
+ end
65
+
66
+ def free_shift(grid, piece)
67
+ limit = [grid.width, grid.height].max
68
+ shift = [0, 0]
69
+ while grid.intersects?(piece, *shift)
70
+ shift = next_shift(*shift)
71
+ next if shift.all? { |step| step.abs <= limit }
72
+
73
+ raise Elkrb::Error, "polyomino packing found no free position"
74
+ end
75
+ shift
76
+ end
77
+
78
+ # ELK's SuccessorLineByLine: the walk 0,0 -> 1,0 -> ... along each
79
+ # row of an expanding square, rows top to bottom.
80
+ def next_shift(col, row)
81
+ return corner_shift(col, row) if col >= 0 && col.abs == row.abs
82
+ return [col + 1, row] unless col.abs > row.abs
83
+
84
+ [-col, col.negative? ? row : row + 1]
85
+ end
86
+
87
+ def corner_shift(col, row)
88
+ return [-col - 1, -col - 1] if col == row
89
+
90
+ [-col, row + 1]
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "grid"
4
+
5
+ module Elkrb
6
+ module Layout
7
+ module Polyomino
8
+ # A grid plus the position the packer gave it, in cells of the packing
9
+ # grid. +payload+ is whatever the caller wants back (a component).
10
+ class Piece < Grid
11
+ attr_accessor :col, :row
12
+ attr_reader :payload
13
+
14
+ def initialize(width, height, payload = nil)
15
+ super(width, height)
16
+ @payload = payload
17
+ @col = 0
18
+ @row = 0
19
+ end
20
+
21
+ # Blocks every empty cell that has a blocked cell somewhere to its
22
+ # north, south, east and west, so a small piece cannot be packed into
23
+ # the hole of a large one. Profile Fill of Freivalds et al. (2002).
24
+ def fill_enclosed_cells
25
+ enclosed_cells.each { |col, row| block(col, row) }
26
+ end
27
+
28
+ private
29
+
30
+ def enclosed_cells
31
+ north, south = extents(columns)
32
+ east, west = extents(rows)
33
+ positions.select do |col, row|
34
+ between?(col, east[row], west[row]) &&
35
+ between?(row, north[col], south[col])
36
+ end
37
+ end
38
+
39
+ # Per line, the index of its first and of its last blocked cell; an
40
+ # empty line gets the values that no cell can lie between.
41
+ def extents(lines)
42
+ [lines.map { |line| line.index(true) || line.size },
43
+ lines.map { |line| line.rindex(true) || -1 }]
44
+ end
45
+
46
+ def between?(value, low, high)
47
+ low < value && value < high
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end