Coverage for moptipyapps / binpacking2d / plot_packing.py: 93%
85 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 04:40 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 04:40 +0000
1"""Plot a packing into one figure."""
2from collections import Counter
3from typing import Callable, Final, Iterable
5import moptipy.utils.plot_defaults as pd
6import moptipy.utils.plot_utils as pu
7from matplotlib.artist import Artist # type: ignore
8from matplotlib.axes import Axes # type: ignore
9from matplotlib.figure import Figure # type: ignore
10from matplotlib.patches import Rectangle # type: ignore
11from matplotlib.text import Text # type: ignore
12from pycommons.types import type_error
14from moptipyapps.binpacking2d.packing import (
15 IDX_BIN,
16 IDX_BOTTOM_Y,
17 IDX_ID,
18 IDX_LEFT_X,
19 IDX_RIGHT_X,
20 IDX_TOP_Y,
21 Packing,
22)
25def default_packing_item_str(item_id: int, item_index: int,
26 item_in_bin_index: int) -> Iterable[str]:
27 """
28 Get a packing item string(s).
30 The default idea is to include the item id, the index of the item in the
31 bin, and the overall index of the item. If the space is insufficient,
32 we remove the latter or the latter two. Hence, this function returns a
33 tuple of three strings.
35 :param item_id: the ID of the packing item
36 :param item_index: the item index
37 :param item_in_bin_index: the index of the item in its bin
38 :return: the string
39 """
40 return (f"{item_id}/{item_in_bin_index}/{item_index}",
41 f"{item_id}/{item_in_bin_index}", str(item_id))
44def plot_packing(packing: Packing | str,
45 max_rows: int = 3,
46 max_bins_per_row: int = 3,
47 default_width_per_bin: float | int | None = 8.6,
48 max_width: float | int | None = 8.6,
49 default_height_per_bin: float | int | None =
50 5.315092303249095,
51 max_height: float | int | None = 9,
52 packing_item_str: Callable[
53 [int, int, int], str | Iterable[str]] =
54 default_packing_item_str,
55 importance_to_font_size_func: Callable[[int], float] =
56 pd.importance_to_font_size,
57 dpi: float | int | None = 384.0) -> Figure:
58 """
59 Plot a packing.
61 Each item is drawn in a different color. Each item rectangle includes, if
62 there is enough space, the item-ID. If there is more space, also the index
63 of the item inside the bin (starting at 1) is included. If there is yet
64 more space, even the overall index of the item is included.
66 :param packing: the packing or the file to load it from
67 :param max_rows: the maximum number of rows
68 :param max_bins_per_row: the maximum number of bins per row
69 :param default_width_per_bin: the optional default width of a column
70 :param max_height: the maximum height
71 :param default_height_per_bin: the optional default height per row
72 :param max_width: the maximum width
73 :param packing_item_str: the function converting an item id,
74 item index, and item-in-bin index to a string or sequence of strings
75 (of decreasing length)
76 :param importance_to_font_size_func: the function converting
77 importance values to font sizes
78 :param dpi: the dpi value
79 :returns: the Figure object to allow you to add further plot elements
80 """
81 if isinstance(packing, str):
82 packing = Packing.from_log(packing)
83 if not isinstance(packing, Packing):
84 raise type_error(packing, "packing", (Packing, str))
85 if not callable(packing_item_str):
86 raise type_error(packing_item_str, "packing_item_str", call=True)
88 # allocate the figure ... this is hacky for now
89 figure, bin_figures = pu.create_figure_with_subplots(
90 items=packing.n_bins, max_items_per_plot=1, max_rows=max_rows,
91 max_cols=max_bins_per_row, min_rows=1, min_cols=1,
92 default_width_per_col=default_width_per_bin, max_width=max_width,
93 default_height_per_row=default_height_per_bin, max_height=max_height,
94 dpi=dpi)
96 # initialize the different plots
97 bin_width: Final[int] = packing.instance.bin_width
98 bin_height: Final[int] = packing.instance.bin_height
99 axes_list: Final[list[Axes]] = []
100 for the_axes, _, _, _, _, _ in bin_figures:
101 axes = pu.get_axes(the_axes)
102 axes_list.append(axes)
103 axes.set_ylim(0, bin_width) # pylint: disable=E1101
104 axes.set_ybound(0, bin_height) # pylint: disable=E1101
105 axes.set_xlim(0, bin_width) # pylint: disable=E1101
106 axes.set_xbound(0, bin_width) # pylint: disable=E1101
107 axes.set_aspect("equal", None, "C") # pylint: disable=E1101
108 axes.tick_params( # pylint: disable=E1101
109 left=False, bottom=False, labelleft=False, labelbottom=False)
111 # get the color and font styles
112 colors: Final[tuple] = pd.distinct_colors(
113 packing.instance.n_different_items)
114 font_size: Final[float] = importance_to_font_size_func(-1)
116 # get the transforms needed to obtain text dimensions
117 renderers: Final[list] = [pu.get_renderer(axes) for axes in axes_list]
118 inverse: Final[list] = [
119 axes.transData.inverted() # type: ignore # pylint: disable=E1101
120 for axes in axes_list]
122 z_order: int = 0 # the z-order of all drawing elements
124 # we now plot the items one-by-one
125 bin_counters: Counter[int] = Counter()
126 for item_index in range(packing.instance.n_items):
127 item_id: int = int(packing[item_index, IDX_ID])
128 item_bin: int = int(packing[item_index, IDX_BIN])
129 x_left: int = int(packing[item_index, IDX_LEFT_X])
130 y_bottom: int = int(packing[item_index, IDX_BOTTOM_Y])
131 x_right: int = int(packing[item_index, IDX_RIGHT_X])
132 y_top: int = int(packing[item_index, IDX_TOP_Y])
133 item_in_bin_index: int = bin_counters[item_bin] + 1
134 bin_counters[item_bin] = item_in_bin_index
135 width: int = x_right - x_left
136 height: int = y_top - y_bottom
138 background = colors[item_id - 1]
139 foreground = pd.text_color_for_background(colors[item_id - 1])
141 axes = axes_list[item_bin - 1]
142 rend = renderers[item_bin - 1]
143 inv = inverse[item_bin - 1]
145 axes.add_artist(Rectangle( # paint the item's rectangle
146 xy=(x_left, y_bottom), width=width, height=height,
147 facecolor=background, linewidth=0.75, zorder=z_order,
148 edgecolor="black"))
149 z_order += 1
150 x_center: float = 0.5 * (x_left + x_right)
151 y_center: float = 0.5 * (y_bottom + y_top)
153# get the box label string or string sequence
154 strs = packing_item_str(item_id, item_index + 1, item_in_bin_index)
155 if isinstance(strs, str):
156 strs = [strs]
157 elif not isinstance(strs, Iterable):
158 raise type_error(
159 strs, f"packing_item_str({item_id}, {item_index}, "
160 f"{item_in_bin_index})", (str, Iterable))
162# iterate over the possible box label strings
163 for i, item_str in enumerate(strs):
164 if not isinstance(item_str, str):
165 raise type_error(
166 str, f"packing_item_str({item_id}, {item_index}, "
167 f"{item_in_bin_index})[{i}]", str)
168# Get the size of the text using a temporary text that gets immediately
169# deleted again.
170 tmp: Text = axes.text(x=x_center, y=y_center, s=item_str,
171 fontsize=font_size,
172 color=foreground,
173 horizontalalignment="center",
174 verticalalignment="baseline")
175 bb_bl = inv.transform_bbox(tmp.get_window_extent(
176 renderer=rend))
177 Artist.set_visible(tmp, False)
178 Artist.remove(tmp)
179 del tmp
181# Check if this text did fit into the rectangle.
182 if (bb_bl.width < (0.97 * width)) and \
183 (bb_bl.height < (0.97 * height)):
184 # OK, there is enough space. Let's re-compute the y offset
185 # to do proper alignment using another temporary text.
186 tmp = axes.text(x=x_center, y=y_center, s=item_str,
187 fontsize=font_size,
188 color=foreground,
189 horizontalalignment="center",
190 verticalalignment="bottom")
191 bb_bt = inv.transform_bbox(tmp.get_window_extent(
192 renderer=rend))
193 Artist.set_visible(tmp, False)
194 Artist.remove(tmp)
195 del tmp
197# Now we can really print the actual text with a more or less nice vertical
198# alignment.
199 adj = bb_bl.y0 - bb_bt.y0
200 if adj < 0:
201 y_center += adj / 3
203 axes.text(x=x_center, y=y_center, s=item_str,
204 fontsize=font_size,
205 color=foreground,
206 horizontalalignment="center",
207 verticalalignment="center",
208 zorder=z_order)
209 z_order += 1
210 break # We found a text that fits, so we can quit.
211 return figure