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.
- checksums.yaml +4 -4
- data/CHANGELOG.adoc +364 -0
- data/LICENSE +25 -0
- data/README.adoc +695 -557
- data/exe/elkrb +36 -2
- data/lib/elkrb/atomic_destination.rb +53 -0
- data/lib/elkrb/best_effort_write.rb +49 -0
- data/lib/elkrb/cli/layout_flag_options.rb +54 -0
- data/lib/elkrb/cli.rb +155 -82
- data/lib/elkrb/command_resolver.rb +186 -0
- data/lib/elkrb/commands/batch_command.rb +28 -5
- data/lib/elkrb/commands/convert_command.rb +22 -52
- data/lib/elkrb/commands/diagram_command.rb +234 -102
- data/lib/elkrb/commands/options_command.rb +87 -0
- data/lib/elkrb/commands/render_command.rb +6 -1
- data/lib/elkrb/commands/validate_command.rb +146 -91
- data/lib/elkrb/errors.rb +20 -1
- data/lib/elkrb/format_sniffer.rb +362 -0
- data/lib/elkrb/geometry/bezier.rb +14 -4
- data/lib/elkrb/geometry/point.rb +5 -6
- data/lib/elkrb/geometry/rectangle.rb +2 -2
- data/lib/elkrb/graph/deep_stringify_keys.rb +30 -0
- data/lib/elkrb/graph/edge.rb +89 -3
- data/lib/elkrb/graph/graph.rb +43 -15
- data/lib/elkrb/graph/label.rb +16 -2
- data/lib/elkrb/graph/node.rb +13 -2
- data/lib/elkrb/graph/node_constraints.rb +139 -8
- data/lib/elkrb/graph/normalize_option_keys.rb +39 -0
- data/lib/elkrb/graph/port.rb +29 -21
- data/lib/elkrb/graph/read_only_mapping.rb +21 -0
- data/lib/elkrb/graphviz_wrapper.rb +152 -44
- data/lib/elkrb/layout/algorithm_registry.rb +65 -8
- data/lib/elkrb/layout/algorithms/base_algorithm.rb +74 -74
- data/lib/elkrb/layout/algorithms/box.rb +47 -20
- data/lib/elkrb/layout/algorithms/disco.rb +150 -61
- data/lib/elkrb/layout/algorithms/fixed.rb +70 -10
- data/lib/elkrb/layout/algorithms/force.rb +145 -86
- data/lib/elkrb/layout/algorithms/layered/bk/aligner.rb +104 -0
- data/lib/elkrb/layout/algorithms/layered/bk/compactor.rb +87 -0
- data/lib/elkrb/layout/algorithms/layered/bk/view.rb +103 -0
- data/lib/elkrb/layout/algorithms/layered/bk_node_placer.rb +170 -0
- data/lib/elkrb/layout/algorithms/layered/components.rb +170 -0
- data/lib/elkrb/layout/algorithms/layered/crossing_minimizer.rb +306 -0
- data/lib/elkrb/layout/algorithms/layered/cycle_breaker.rb +118 -49
- data/lib/elkrb/layout/algorithms/layered/discovery_order.rb +75 -0
- data/lib/elkrb/layout/algorithms/layered/dummy_slot.rb +31 -0
- data/lib/elkrb/layout/algorithms/layered/layer_assigner.rb +188 -48
- data/lib/elkrb/layout/algorithms/layered/layer_constraints.rb +82 -0
- data/lib/elkrb/layout/algorithms/layered/layer_sweep.rb +195 -0
- data/lib/elkrb/layout/algorithms/layered/node_placer.rb +214 -36
- data/lib/elkrb/layout/algorithms/layered/orthogonal/gap_solver.rb +219 -0
- data/lib/elkrb/layout/algorithms/layered/orthogonal/hyper_edge_segment.rb +152 -0
- data/lib/elkrb/layout/algorithms/layered/orthogonal/junction_points.rb +58 -0
- data/lib/elkrb/layout/algorithms/layered/orthogonal/segment_order.rb +134 -0
- data/lib/elkrb/layout/algorithms/layered/orthogonal_router.rb +253 -0
- data/lib/elkrb/layout/algorithms/layered/port_order.rb +188 -0
- data/lib/elkrb/layout/algorithms/layered/port_spread.rb +42 -0
- data/lib/elkrb/layout/algorithms/layered.rb +548 -13
- data/lib/elkrb/layout/algorithms/libavoid.rb +951 -187
- data/lib/elkrb/layout/algorithms/mrtree.rb +585 -65
- data/lib/elkrb/layout/algorithms/radial.rb +188 -29
- data/lib/elkrb/layout/algorithms/random.rb +131 -17
- data/lib/elkrb/layout/algorithms/rectpacking.rb +28 -40
- data/lib/elkrb/layout/algorithms/spore_compaction.rb +57 -100
- data/lib/elkrb/layout/algorithms/spore_overlap.rb +78 -84
- data/lib/elkrb/layout/algorithms/stress.rb +391 -84
- data/lib/elkrb/layout/algorithms/topdown_packing.rb +95 -31
- data/lib/elkrb/layout/algorithms/vertiflex.rb +12 -47
- data/lib/elkrb/layout/constraints/alignment_constraint.rb +49 -12
- data/lib/elkrb/layout/constraints/base_constraint.rb +3 -15
- data/lib/elkrb/layout/constraints/constraint_processor.rb +9 -23
- data/lib/elkrb/layout/constraints/fixed_position_constraint.rb +22 -13
- data/lib/elkrb/layout/constraints/layer_constraint.rb +10 -33
- data/lib/elkrb/layout/constraints/relative_position_constraint.rb +94 -50
- data/lib/elkrb/layout/edge_router.rb +533 -337
- data/lib/elkrb/layout/hierarchical_processor.rb +19 -276
- data/lib/elkrb/layout/java_random.rb +59 -0
- data/lib/elkrb/layout/label_placer.rb +165 -157
- data/lib/elkrb/layout/layout_engine.rb +46 -23
- data/lib/elkrb/layout/node_index.rb +149 -0
- data/lib/elkrb/layout/polyomino/component_compactor.rb +138 -0
- data/lib/elkrb/layout/polyomino/footprint.rb +94 -0
- data/lib/elkrb/layout/polyomino/grid.rb +106 -0
- data/lib/elkrb/layout/polyomino/packer.rb +95 -0
- data/lib/elkrb/layout/polyomino/piece.rb +52 -0
- data/lib/elkrb/layout/port_constraint_processor.rb +131 -44
- data/lib/elkrb/layout/spore/body.rb +65 -0
- data/lib/elkrb/layout/spore/depth_first_compaction.rb +127 -0
- data/lib/elkrb/layout/spore/edge_cost.rb +69 -0
- data/lib/elkrb/layout/spore/gap.rb +76 -0
- data/lib/elkrb/layout/spore/geometry.rb +104 -0
- data/lib/elkrb/layout/spore/overlap_sweep.rb +90 -0
- data/lib/elkrb/layout/spore/placement.rb +57 -0
- data/lib/elkrb/layout/spore/rays.rb +119 -0
- data/lib/elkrb/layout/spore/spanning_tree.rb +53 -0
- data/lib/elkrb/layout/spore/triangulation.rb +159 -0
- data/lib/elkrb/layout_flags.rb +69 -0
- data/lib/elkrb/options/decimal.rb +50 -0
- data/lib/elkrb/options/element_options.rb +43 -0
- data/lib/elkrb/options/elk_padding.rb +20 -14
- data/lib/elkrb/options/k_vector.rb +6 -7
- data/lib/elkrb/options/k_vector_chain.rb +12 -20
- data/lib/elkrb/options/option_map.rb +85 -0
- data/lib/elkrb/options/registry.rb +335 -0
- data/lib/elkrb/options/resolver.rb +151 -0
- data/lib/elkrb/options/spellings.rb +62 -0
- data/lib/elkrb/options/unhonoured_report.rb +194 -0
- data/lib/elkrb/parsers/elkt/lexer.rb +309 -0
- data/lib/elkrb/parsers/elkt/parser.rb +424 -0
- data/lib/elkrb/parsers/elkt/resolver.rb +142 -0
- data/lib/elkrb/parsers/elkt/token.rb +40 -0
- data/lib/elkrb/parsers/elkt_parser.rb +32 -230
- data/lib/elkrb/serializers/dot_serializer.rb +342 -54
- data/lib/elkrb/serializers/elkt_serializer.rb +386 -155
- data/lib/elkrb/version.rb +1 -1
- data/lib/elkrb.rb +60 -61
- metadata +71 -49
- data/.rspec +0 -3
- data/.rubocop.yml +0 -11
- data/Gemfile +0 -13
- data/Rakefile +0 -64
- data/benchmarks/README.md +0 -172
- data/benchmarks/elkjs_benchmark.js +0 -140
- data/benchmarks/elkrb_benchmark.rb +0 -145
- data/benchmarks/fixtures/graphs.json +0 -10777
- data/benchmarks/generate_report.rb +0 -241
- data/benchmarks/generate_test_graphs.rb +0 -154
- data/benchmarks/results/elkrb_results.json +0 -280
- data/benchmarks/results/elkrb_summary.json +0 -285
- data/elkrb.gemspec +0 -39
- data/examples/dot_export_demo.rb +0 -133
- data/examples/hierarchical_graph.rb +0 -19
- data/examples/layout_constraints_demo.rb +0 -272
- data/examples/port_constraints_demo.rb +0 -291
- data/examples/self_loop_demo.rb +0 -391
- data/examples/simple_graph.rb +0 -50
- data/examples/spline_routing_demo.rb +0 -235
- data/lib/elkrb/graph/layout_options.rb +0 -247
- data/sig/elkrb/constraints.rbs +0 -114
- data/sig/elkrb/geometry.rbs +0 -61
- data/sig/elkrb/graph.rbs +0 -112
- data/sig/elkrb/layout.rbs +0 -107
- data/sig/elkrb/options.rbs +0 -81
- 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
|