polycubetools 1.1.0__py3-none-any.whl
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.
- polycubetools/__init__.py +19 -0
- polycubetools/data/heptacubes.json +945197 -0
- polycubetools/data/hexacubes.json +122667 -0
- polycubetools/data/pentacubes.json +16167 -0
- polycubetools/grid.py +192 -0
- polycubetools/hull.py +121 -0
- polycubetools/models.py +172 -0
- polycubetools/py.typed +0 -0
- polycubetools/solver.py +178 -0
- polycubetools/utils.py +198 -0
- polycubetools-1.1.0.dist-info/METADATA +44 -0
- polycubetools-1.1.0.dist-info/RECORD +15 -0
- polycubetools-1.1.0.dist-info/WHEEL +5 -0
- polycubetools-1.1.0.dist-info/licenses/LICENSE +21 -0
- polycubetools-1.1.0.dist-info/top_level.txt +1 -0
polycubetools/grid.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Self, TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from .models import Coordinate
|
|
7
|
+
from .utils import is_valid_fence
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
import os
|
|
11
|
+
from .hull import AbstractHull
|
|
12
|
+
from .models import RotatedPolycube, JSON_SOLUTION, SNAPSHOT
|
|
13
|
+
|
|
14
|
+
__all__ = ("Grid", "HullGrid")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Grid:
|
|
18
|
+
def __init__(self, size: Coordinate, offset: Coordinate = Coordinate(0, 0, 0)) -> None:
|
|
19
|
+
"""Creates a cuboid grid with the given size and offset."""
|
|
20
|
+
self._size = size
|
|
21
|
+
self._offset = offset
|
|
22
|
+
self._offset_size = size + offset
|
|
23
|
+
self._cells: dict[Coordinate, int] = {}
|
|
24
|
+
"""Grid cells, mapping coordinates to polycube ids."""
|
|
25
|
+
|
|
26
|
+
def __contains__(self, item: object) -> bool:
|
|
27
|
+
return item in self._cells
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def size(self) -> Coordinate:
|
|
31
|
+
return self._size
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def offset(self) -> Coordinate:
|
|
35
|
+
return self._offset
|
|
36
|
+
|
|
37
|
+
def in_bounds(self, pos: Coordinate) -> bool:
|
|
38
|
+
"""Checks if the given coordinate is within the grid bounds."""
|
|
39
|
+
offset, offset_size = self._offset, self._offset_size
|
|
40
|
+
px, py, pz = pos.x, pos.y, pos.z
|
|
41
|
+
return offset.x <= px < offset_size.x and offset.y <= py < offset_size.y and offset.z <= pz < offset_size.z
|
|
42
|
+
|
|
43
|
+
def place(self, polycube: RotatedPolycube, offset: Coordinate) -> None:
|
|
44
|
+
"""Places the polycube at the given offset.
|
|
45
|
+
Assumes that the polycube can be placed, use can_place to check beforehand!
|
|
46
|
+
"""
|
|
47
|
+
cells = self._cells
|
|
48
|
+
for coord in polycube.coords:
|
|
49
|
+
cells[coord + offset] = polycube.id
|
|
50
|
+
|
|
51
|
+
def can_place(self, polycube: RotatedPolycube, offset: Coordinate) -> bool:
|
|
52
|
+
"""Checks if the polycube can be placed at the given offset, or more precisely,
|
|
53
|
+
if every cell it would occupy is empty and in bounds.
|
|
54
|
+
"""
|
|
55
|
+
cells, in_bounds = self._cells, self.in_bounds
|
|
56
|
+
for coord in polycube.coords:
|
|
57
|
+
coord += offset
|
|
58
|
+
if coord in cells or not in_bounds(coord):
|
|
59
|
+
return False
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
def to_json(self, filename: str | os.PathLike[str] | None = None) -> JSON_SOLUTION:
|
|
63
|
+
"""Exports the current grid into the JSON format, returns it, and writes it to a file if specified."""
|
|
64
|
+
output: JSON_SOLUTION = [[self.size.x, self.size.y, self.size.z]]
|
|
65
|
+
output.extend([[_id, coord.x, coord.y, coord.z] for coord, _id in self._cells.items()])
|
|
66
|
+
|
|
67
|
+
if filename is not None:
|
|
68
|
+
with open(filename, "w") as f:
|
|
69
|
+
json.dump(output, f, indent=2)
|
|
70
|
+
|
|
71
|
+
return output
|
|
72
|
+
|
|
73
|
+
def format(self) -> str:
|
|
74
|
+
"""Exports the current grid into a human-readable format."""
|
|
75
|
+
|
|
76
|
+
raise NotImplementedError
|
|
77
|
+
|
|
78
|
+
def merge(self, other: Grid) -> None:
|
|
79
|
+
"""Copies all cubes from another grid into this one, according to the offset of the grids."""
|
|
80
|
+
|
|
81
|
+
raise NotImplementedError
|
|
82
|
+
|
|
83
|
+
def validate(self) -> bool:
|
|
84
|
+
"""Checks if there is a valid polycube fence in the grid."""
|
|
85
|
+
return is_valid_fence(set(self._cells.keys()))
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def from_snapshot(cls, snapshot: SNAPSHOT) -> Self:
|
|
89
|
+
"""Reconstructs a grid from a snapshot and a hull.
|
|
90
|
+
Snapshot must have been generated by to_snapshot().
|
|
91
|
+
"""
|
|
92
|
+
size = Coordinate.from_tuple(snapshot["size"])
|
|
93
|
+
offset = Coordinate.from_tuple(snapshot["offset"])
|
|
94
|
+
cells = {Coordinate.from_tuple(t): i for t, i in snapshot["cells"].items()}
|
|
95
|
+
|
|
96
|
+
self = cls(size, offset)
|
|
97
|
+
self._cells = cells
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
def to_snapshot(self) -> SNAPSHOT:
|
|
101
|
+
"""Retrieves a pickable snapshot of the grid, reconstructible with from_snapshot()"""
|
|
102
|
+
return {
|
|
103
|
+
"size": self._size.to_tuple(),
|
|
104
|
+
"offset": self._offset.to_tuple(),
|
|
105
|
+
"cells": {c.to_tuple(): i for c, i in self._cells.items()},
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class HullGrid[H: AbstractHull](Grid):
|
|
110
|
+
def __init__(self, size: Coordinate, hull: H, offset: Coordinate = Coordinate(0, 0, 0)) -> None:
|
|
111
|
+
"""Creates a Grid that contains a hull and allows certain operations on it."""
|
|
112
|
+
super().__init__(size, offset)
|
|
113
|
+
self._hull = hull
|
|
114
|
+
self._active_frontier: set[Coordinate] = set()
|
|
115
|
+
|
|
116
|
+
def place(self, polycube: RotatedPolycube, offset: Coordinate) -> None:
|
|
117
|
+
super().place(polycube, offset)
|
|
118
|
+
self._hull.update_frontier(polycube, offset)
|
|
119
|
+
self._update_active_frontier(polycube, offset)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def hull(self) -> H:
|
|
123
|
+
"""Direct access to the hull, prefer using the proxied methods when possible:
|
|
124
|
+
frontier, active_frontier, in_hull, in_inner_part
|
|
125
|
+
"""
|
|
126
|
+
return self._hull
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def frontier(self) -> frozenset[Coordinate]:
|
|
130
|
+
"""Returns a frozen view of the current frontier coordinates."""
|
|
131
|
+
return frozenset(self._hull.frontier)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def active_frontier(self) -> frozenset[Coordinate]:
|
|
135
|
+
"""Returns a frozen view of the current active frontier coordinates."""
|
|
136
|
+
return frozenset(self._active_frontier)
|
|
137
|
+
|
|
138
|
+
@active_frontier.setter
|
|
139
|
+
def active_frontier(self, value: set[Coordinate]) -> None:
|
|
140
|
+
"""Sets the active frontier coordinates. Only use this if you know what you're doing"""
|
|
141
|
+
self._active_frontier = value
|
|
142
|
+
|
|
143
|
+
def _update_active_frontier(self, polycube: RotatedPolycube, offset: Coordinate) -> None:
|
|
144
|
+
"""Remove the coordinates of the polycube at the offset from the frontier, if present."""
|
|
145
|
+
placed_points = [point + offset for point in polycube.coords]
|
|
146
|
+
placed_points_neighbors = [point + offset for point in polycube.get_all_neighbor_cells()]
|
|
147
|
+
|
|
148
|
+
for placed_point in placed_points:
|
|
149
|
+
if placed_point not in self._active_frontier:
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
self._active_frontier.remove(placed_point)
|
|
153
|
+
|
|
154
|
+
for point in placed_points_neighbors:
|
|
155
|
+
if point in self._cells or point not in self._hull.frontier:
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
self._active_frontier.add(point)
|
|
159
|
+
|
|
160
|
+
def in_hull(self, pos: Coordinate) -> bool:
|
|
161
|
+
"""Checks if the given coordinate is inside the hull."""
|
|
162
|
+
return self._hull.in_hull(pos)
|
|
163
|
+
|
|
164
|
+
def in_inner_part(self, pos: Coordinate) -> bool:
|
|
165
|
+
"""Checks if the given coordinate is inside the inner part of the hull."""
|
|
166
|
+
return self._hull.in_inner_part(pos)
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
def from_snapshot(cls, snapshot: SNAPSHOT, hull: H | None = None) -> Self:
|
|
170
|
+
"""Reconstructs a grid from a snapshot and a hull.
|
|
171
|
+
Snapshot must have been generated by to_snapshot().
|
|
172
|
+
"""
|
|
173
|
+
if hull is None:
|
|
174
|
+
raise ValueError("Hull must be provided when reconstructing a HullGrid from a snapshot.")
|
|
175
|
+
|
|
176
|
+
size = Coordinate.from_tuple(snapshot["size"])
|
|
177
|
+
offset = Coordinate.from_tuple(snapshot["offset"])
|
|
178
|
+
cells = {Coordinate.from_tuple(t): i for t, i in snapshot["cells"].items()}
|
|
179
|
+
active_frontier = {Coordinate.from_tuple(t) for t in snapshot["active_frontier"]}
|
|
180
|
+
|
|
181
|
+
self = cls(size, hull, offset)
|
|
182
|
+
self._cells = cells
|
|
183
|
+
self._active_frontier = active_frontier
|
|
184
|
+
return self
|
|
185
|
+
|
|
186
|
+
def to_snapshot(self) -> SNAPSHOT:
|
|
187
|
+
"""Retrieves a pickable snapshot of the grid, reconstructible with from_snapshot()
|
|
188
|
+
Note: You have to serialize the hull separately, use Hull.to_snapshot and Hull.from_snapshot.
|
|
189
|
+
"""
|
|
190
|
+
snapshot = super().to_snapshot()
|
|
191
|
+
snapshot["active_frontier"] = tuple(c.to_tuple() for c in self._active_frontier)
|
|
192
|
+
return snapshot
|
polycubetools/hull.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Self, TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from .models import Coordinate
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .models import RotatedPolycube, SNAPSHOT
|
|
10
|
+
|
|
11
|
+
__all__ = ("AbstractHull", "CoordinateHull", "CuboidHull")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AbstractHull(ABC):
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self.frontier: set[Coordinate] = set()
|
|
17
|
+
|
|
18
|
+
def update_frontier(self, polycube: RotatedPolycube, offset: Coordinate) -> None:
|
|
19
|
+
"""Remove the coordinates of the polycube at the offset from the frontier, if present."""
|
|
20
|
+
self.frontier.difference_update(c + offset for c in polycube.coords)
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def from_snapshot(cls, snapshot: SNAPSHOT) -> Self:
|
|
25
|
+
"""Constructs a hull from a snapshot."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def to_snapshot(self) -> SNAPSHOT:
|
|
30
|
+
"""Exports the hull into a snapshot."""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def in_hull(self, pos: Coordinate) -> bool:
|
|
35
|
+
"""Checks if the given coordinate is inside the hull."""
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def in_inner_part(self, pos: Coordinate) -> bool:
|
|
40
|
+
"""Checks if the given coordinate is inside the inner part of the hull."""
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CoordinateHull(AbstractHull):
|
|
45
|
+
def __init__(self, coords: frozenset[Coordinate], frontier: set[Coordinate] | None = None) -> None:
|
|
46
|
+
super().__init__()
|
|
47
|
+
self.coords = coords
|
|
48
|
+
|
|
49
|
+
if frontier is None:
|
|
50
|
+
self.frontier = set(coords)
|
|
51
|
+
else:
|
|
52
|
+
self.frontier = frontier
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_snapshot(cls, snapshot: SNAPSHOT) -> Self:
|
|
56
|
+
coords = frozenset(Coordinate.from_tuple(t) for t in snapshot["coords"])
|
|
57
|
+
frontier = {Coordinate.from_tuple(t) for t in snapshot["frontier"]}
|
|
58
|
+
|
|
59
|
+
return cls(coords, frontier)
|
|
60
|
+
|
|
61
|
+
def to_snapshot(self) -> SNAPSHOT:
|
|
62
|
+
return {
|
|
63
|
+
"coords": tuple(c.to_tuple() for c in self.coords),
|
|
64
|
+
"frontier": tuple(c.to_tuple() for c in self.frontier),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
def in_hull(self, pos: Coordinate) -> bool:
|
|
68
|
+
return pos in self.coords
|
|
69
|
+
|
|
70
|
+
def in_inner_part(self, pos: Coordinate) -> bool:
|
|
71
|
+
raise NotImplementedError(
|
|
72
|
+
"Inner part is not defined for coordinate hulls (you can implement it in a subclass if needed)"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class CuboidHull(AbstractHull):
|
|
77
|
+
def __init__(self, min_coord: Coordinate, max_coord: Coordinate, frontier: set[Coordinate] | None = None) -> None:
|
|
78
|
+
super().__init__()
|
|
79
|
+
self.min_coord = min_coord
|
|
80
|
+
self.max_coord = max_coord
|
|
81
|
+
|
|
82
|
+
if frontier is None:
|
|
83
|
+
self.init_frontier()
|
|
84
|
+
else:
|
|
85
|
+
self.frontier = frontier
|
|
86
|
+
|
|
87
|
+
def init_frontier(self):
|
|
88
|
+
"""Initialize the frontier with all the coordinates of the cuboid."""
|
|
89
|
+
self.frontier.clear()
|
|
90
|
+
minc, maxc = self.min_coord, self.max_coord
|
|
91
|
+
|
|
92
|
+
for x in range(self.min_coord.x, self.max_coord.x + 1):
|
|
93
|
+
for y in range(self.min_coord.y, self.max_coord.y + 1):
|
|
94
|
+
for z in range(self.min_coord.z, self.max_coord.z + 1):
|
|
95
|
+
if x == minc.x or x == maxc.x or y == minc.y or y == maxc.y or z == minc.z or z == maxc.z:
|
|
96
|
+
self.frontier.add(Coordinate(x, y, z))
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def from_snapshot(cls, snapshot: SNAPSHOT) -> Self:
|
|
100
|
+
minc = Coordinate.from_tuple(snapshot["min"])
|
|
101
|
+
maxc = Coordinate.from_tuple(snapshot["max"])
|
|
102
|
+
frontier = {Coordinate.from_tuple(t) for t in snapshot["frontier"]}
|
|
103
|
+
|
|
104
|
+
return cls(minc, maxc, frontier)
|
|
105
|
+
|
|
106
|
+
def to_snapshot(self) -> SNAPSHOT:
|
|
107
|
+
return {
|
|
108
|
+
"min": self.min_coord.to_tuple(),
|
|
109
|
+
"max": self.max_coord.to_tuple(),
|
|
110
|
+
"frontier": tuple(c.to_tuple() for c in self.frontier),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
def in_hull(self, pos: Coordinate) -> bool:
|
|
114
|
+
minc, maxc = self.min_coord, self.max_coord
|
|
115
|
+
px, py, pz = pos.x, pos.y, pos.z
|
|
116
|
+
return px == minc.x or px == maxc.x or py == minc.y or py == maxc.y or pz == minc.z or pz == maxc.z
|
|
117
|
+
|
|
118
|
+
def in_inner_part(self, pos: Coordinate) -> bool:
|
|
119
|
+
minc, maxc = self.min_coord, self.max_coord
|
|
120
|
+
px, py, pz = pos.x, pos.y, pos.z
|
|
121
|
+
return minc.x < px < maxc.x and minc.y < py < maxc.y and minc.z < pz < maxc.z
|
polycubetools/models.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
__all__ = (
|
|
6
|
+
"Coordinate",
|
|
7
|
+
"Placement",
|
|
8
|
+
"Polycube",
|
|
9
|
+
"PolycubePlacement",
|
|
10
|
+
"RotatedPolycube",
|
|
11
|
+
"JSON_SOLUTION",
|
|
12
|
+
"SIX_NEIGHBORHOOD_DIRECTIONS",
|
|
13
|
+
"SNAPSHOT",
|
|
14
|
+
"TWENTY_SIX_NEIGHBORHOOD_DIRECTIONS",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from typing import Any, Self
|
|
18
|
+
|
|
19
|
+
type JSON_SOLUTION = list[list[int | str]]
|
|
20
|
+
type SNAPSHOT = dict[str, Any]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class Coordinate:
|
|
25
|
+
"""Represents a single point in a 3D grid."""
|
|
26
|
+
|
|
27
|
+
x: int
|
|
28
|
+
y: int
|
|
29
|
+
z: int
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_tuple(cls, coords: tuple[int, int, int]) -> Coordinate:
|
|
33
|
+
return cls(*coords)
|
|
34
|
+
|
|
35
|
+
def to_tuple(self) -> tuple[int, int, int]:
|
|
36
|
+
return self.x, self.y, self.z
|
|
37
|
+
|
|
38
|
+
def __add__(self, other: Coordinate) -> Coordinate:
|
|
39
|
+
"""Pairwise addition of coordinates."""
|
|
40
|
+
return Coordinate(self.x + other.x, self.y + other.y, self.z + other.z)
|
|
41
|
+
|
|
42
|
+
def __sub__(self, other: Coordinate) -> Coordinate:
|
|
43
|
+
"""Pairwise subtraction of coordinates."""
|
|
44
|
+
return Coordinate(self.x - other.x, self.y - other.y, self.z - other.z)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class RotatedPolycube:
|
|
49
|
+
"""Represents a specific rotation of a polycube."""
|
|
50
|
+
|
|
51
|
+
id: int
|
|
52
|
+
"""The id of the base polycube."""
|
|
53
|
+
coords: frozenset[Coordinate]
|
|
54
|
+
"""The coordinates of the n unit-cubes that make up the polycube."""
|
|
55
|
+
|
|
56
|
+
def get_all_neighbor_cells(self) -> frozenset[Coordinate]:
|
|
57
|
+
"""Return all 6-neighborhood cells adjacent to this rotated polycube."""
|
|
58
|
+
return frozenset(
|
|
59
|
+
[
|
|
60
|
+
point + offset
|
|
61
|
+
for point in self.coords
|
|
62
|
+
for offset in SIX_NEIGHBORHOOD_DIRECTIONS
|
|
63
|
+
if point + offset not in self.coords
|
|
64
|
+
]
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class Polycube:
|
|
70
|
+
"""Represents a single n-polycube."""
|
|
71
|
+
|
|
72
|
+
id: int
|
|
73
|
+
rotations: tuple[RotatedPolycube, ...]
|
|
74
|
+
"""All unique rotations of this polycube."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class Placement:
|
|
79
|
+
"""Represents a placement of a specifically rotated polycube on a specific coordinate."""
|
|
80
|
+
|
|
81
|
+
rotated_polycube: RotatedPolycube
|
|
82
|
+
pos: Coordinate
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_snapshot(cls, snapshot: SNAPSHOT) -> Self:
|
|
86
|
+
rpc_id = snapshot["rotated_polycube_id"]
|
|
87
|
+
rpc_coords = frozenset(Coordinate.from_tuple(t) for t in snapshot["rotated_polycube_coords"])
|
|
88
|
+
rpc = RotatedPolycube(rpc_id, rpc_coords)
|
|
89
|
+
pos = Coordinate.from_tuple(snapshot["pos"])
|
|
90
|
+
return cls(rpc, pos)
|
|
91
|
+
|
|
92
|
+
def to_snapshot(self) -> SNAPSHOT:
|
|
93
|
+
return {
|
|
94
|
+
"rotated_polycube_id": self.rotated_polycube.id,
|
|
95
|
+
"rotated_polycube_coords": tuple(c.to_tuple() for c in self.rotated_polycube.coords),
|
|
96
|
+
"pos": self.pos.to_tuple(),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True, slots=True)
|
|
101
|
+
class PolycubePlacement:
|
|
102
|
+
"""Represents a placement of a generic polycube, so all its rotations on a specific coordinate."""
|
|
103
|
+
|
|
104
|
+
polycube: Polycube
|
|
105
|
+
pos: Coordinate
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def from_snapshot(cls, snapshot: SNAPSHOT) -> Self:
|
|
109
|
+
polycube_id = snapshot["polycube_id"]
|
|
110
|
+
rpc_coords = snapshot["rotated_polycube_coords"]
|
|
111
|
+
rpcs = tuple(
|
|
112
|
+
RotatedPolycube(polycube_id, frozenset(Coordinate.from_tuple(t) for t in rpc_coord))
|
|
113
|
+
for rpc_coord in rpc_coords
|
|
114
|
+
)
|
|
115
|
+
polycube = Polycube(polycube_id, rpcs)
|
|
116
|
+
pos = Coordinate.from_tuple(snapshot["pos"])
|
|
117
|
+
return cls(polycube, pos)
|
|
118
|
+
|
|
119
|
+
def to_snapshot(self) -> SNAPSHOT:
|
|
120
|
+
return {
|
|
121
|
+
"polycube_id": self.polycube.id,
|
|
122
|
+
"rotated_polycube_coords": tuple(
|
|
123
|
+
tuple(c.to_tuple() for c in rpc.coords) for rpc in self.polycube.rotations
|
|
124
|
+
),
|
|
125
|
+
"pos": self.pos.to_tuple(),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
SIX_NEIGHBORHOOD_DIRECTIONS = (
|
|
130
|
+
Coordinate(1, 0, 0),
|
|
131
|
+
Coordinate(-1, 0, 0),
|
|
132
|
+
Coordinate(0, 1, 0),
|
|
133
|
+
Coordinate(0, -1, 0),
|
|
134
|
+
Coordinate(0, 0, 1),
|
|
135
|
+
Coordinate(0, 0, -1),
|
|
136
|
+
)
|
|
137
|
+
"""All possible directions in a 3D Grid (without diagonals).
|
|
138
|
+
The neighbors grows additively with the dimension: 2 * dimension."""
|
|
139
|
+
|
|
140
|
+
TWENTY_SIX_NEIGHBORHOOD_DIRECTIONS = (
|
|
141
|
+
# 6 orthogonal directions (6-neighborhood)
|
|
142
|
+
Coordinate(1, 0, 0),
|
|
143
|
+
Coordinate(-1, 0, 0),
|
|
144
|
+
Coordinate(0, 1, 0),
|
|
145
|
+
Coordinate(0, -1, 0),
|
|
146
|
+
Coordinate(0, 0, 1),
|
|
147
|
+
Coordinate(0, 0, -1),
|
|
148
|
+
# 12 edge-diagonal directions (connecting edge centers)
|
|
149
|
+
Coordinate(1, 1, 0),
|
|
150
|
+
Coordinate(1, -1, 0),
|
|
151
|
+
Coordinate(-1, 1, 0),
|
|
152
|
+
Coordinate(-1, -1, 0),
|
|
153
|
+
Coordinate(1, 0, 1),
|
|
154
|
+
Coordinate(1, 0, -1),
|
|
155
|
+
Coordinate(-1, 0, 1),
|
|
156
|
+
Coordinate(-1, 0, -1),
|
|
157
|
+
Coordinate(0, 1, 1),
|
|
158
|
+
Coordinate(0, 1, -1),
|
|
159
|
+
Coordinate(0, -1, 1),
|
|
160
|
+
Coordinate(0, -1, -1),
|
|
161
|
+
# 8 face-diagonal directions (connecting corners)
|
|
162
|
+
Coordinate(1, 1, 1),
|
|
163
|
+
Coordinate(1, 1, -1),
|
|
164
|
+
Coordinate(1, -1, 1),
|
|
165
|
+
Coordinate(1, -1, -1),
|
|
166
|
+
Coordinate(-1, 1, 1),
|
|
167
|
+
Coordinate(-1, 1, -1),
|
|
168
|
+
Coordinate(-1, -1, 1),
|
|
169
|
+
Coordinate(-1, -1, -1),
|
|
170
|
+
)
|
|
171
|
+
"""All possible directions in a 3D Grid.
|
|
172
|
+
The neighbors grows exponentially with the dimension: 3 ^ dimension - 1."""
|
polycubetools/py.typed
ADDED
|
File without changes
|
polycubetools/solver.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from concurrent.futures import Future, ProcessPoolExecutor
|
|
7
|
+
from enum import StrEnum, auto
|
|
8
|
+
from typing import TYPE_CHECKING, Iterable, Sequence, Callable
|
|
9
|
+
|
|
10
|
+
from .grid import HullGrid
|
|
11
|
+
from .models import Placement, PolycubePlacement
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from os import PathLike
|
|
15
|
+
|
|
16
|
+
from .hull import AbstractHull
|
|
17
|
+
from .grid import Grid
|
|
18
|
+
from .models import Polycube, JSON_SOLUTION, SNAPSHOT
|
|
19
|
+
|
|
20
|
+
__all__ = ("AbstractSolver", "ParallelSolver", "ScoreSolver", "SolverStatus")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SolverStatus(StrEnum):
|
|
24
|
+
INITIALIZED = auto()
|
|
25
|
+
RUNNING = auto()
|
|
26
|
+
FINISHED = auto()
|
|
27
|
+
FAILED = auto()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AbstractSolver[G: Grid](ABC):
|
|
31
|
+
def __init__(self, grid: G, polycubes: tuple[Polycube, ...]) -> None:
|
|
32
|
+
"""The base init function. It must be called when being overwritten: super().__init__(grid, polycubes)"""
|
|
33
|
+
self._grid = grid
|
|
34
|
+
self._polycubes = polycubes
|
|
35
|
+
self._status = SolverStatus.INITIALIZED
|
|
36
|
+
self._logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def grid(self) -> G:
|
|
40
|
+
return self._grid
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def polycubes(self) -> tuple[Polycube, ...]:
|
|
44
|
+
return self._polycubes
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def status(self) -> SolverStatus:
|
|
48
|
+
return self._status
|
|
49
|
+
|
|
50
|
+
def set_finished(self) -> None:
|
|
51
|
+
self._status = SolverStatus.FINISHED
|
|
52
|
+
|
|
53
|
+
def set_failed(self, message: str) -> None:
|
|
54
|
+
self._status = SolverStatus.FAILED
|
|
55
|
+
self._logger.error("Run failed: %s", message)
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def step(self) -> None:
|
|
59
|
+
"""Performs a single iteration of the solving logic.
|
|
60
|
+
This method should eventually call self.set_finished() or self.set_failed() to finish the run.
|
|
61
|
+
"""
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
def is_running(self) -> bool:
|
|
65
|
+
return self.status == SolverStatus.RUNNING
|
|
66
|
+
|
|
67
|
+
def run(self, output_file: str | PathLike[str] | None) -> JSON_SOLUTION:
|
|
68
|
+
"""Runs the solver until the status is set to finished or failed.
|
|
69
|
+
Finally returns the grid as JSON and writes it to the specified file if specified.
|
|
70
|
+
"""
|
|
71
|
+
self._status = SolverStatus.RUNNING
|
|
72
|
+
self._logger.info("Solver run started")
|
|
73
|
+
|
|
74
|
+
while self.is_running():
|
|
75
|
+
self.step()
|
|
76
|
+
|
|
77
|
+
self.done()
|
|
78
|
+
return self.grid.to_json(output_file)
|
|
79
|
+
|
|
80
|
+
def done(self):
|
|
81
|
+
self._logger.info("Solver run completed with status: %s", self.status)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _GenericScoreSolver[H: AbstractHull, C, SC, SR](AbstractSolver[HullGrid[H]], ABC):
|
|
85
|
+
def __init__(self, grid: HullGrid[H], polycubes: tuple[Polycube, ...]) -> None:
|
|
86
|
+
super().__init__(grid, polycubes)
|
|
87
|
+
self.remaining: dict[int, Polycube] = {p.id: p for p in polycubes}
|
|
88
|
+
|
|
89
|
+
@abstractmethod
|
|
90
|
+
def score(self, candidate: SC) -> SR:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
@abstractmethod
|
|
94
|
+
def next_candidates(self) -> Sequence[C]:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
@abstractmethod
|
|
98
|
+
def _compute_best(self, candidates: Iterable[C]) -> tuple[Placement | None, int]:
|
|
99
|
+
"""Computes the highest scoring candidate, deterministically."""
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
def after_place(self, placement: Placement) -> None:
|
|
103
|
+
"""Called after a polycube is placed, override if needed, but you should call the super method."""
|
|
104
|
+
del self.remaining[placement.rotated_polycube.id]
|
|
105
|
+
|
|
106
|
+
def step(self) -> None:
|
|
107
|
+
candidates = self.next_candidates()
|
|
108
|
+
if not candidates:
|
|
109
|
+
self.set_failed("No more candidates to try")
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
p, score = self._compute_best(candidates)
|
|
113
|
+
if p is None:
|
|
114
|
+
self.set_failed("No valid placement found")
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
if self._logger.isEnabledFor(logging.DEBUG):
|
|
118
|
+
self._logger.debug("Selected best candidate: %s with score: %d", p, score)
|
|
119
|
+
|
|
120
|
+
if not self.grid.can_place(p.rotated_polycube, p.pos):
|
|
121
|
+
self.set_failed("Found placement is not valid")
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
self.grid.place(p.rotated_polycube, p.pos)
|
|
125
|
+
self.after_place(p)
|
|
126
|
+
if not self.grid.frontier:
|
|
127
|
+
self.set_finished()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ScoreSolver[H: AbstractHull](_GenericScoreSolver[H, Placement, Placement, int], ABC):
|
|
131
|
+
def _compute_best(self, candidates: Iterable[Placement]) -> tuple[Placement | None, int]:
|
|
132
|
+
"""Computes the highest scoring candidate, deterministically."""
|
|
133
|
+
p = max(candidates, key=self.score)
|
|
134
|
+
return p, self.score(p)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
type _snapshots = tuple[SNAPSHOT, SNAPSHOT, SNAPSHOT]
|
|
138
|
+
type _future_result = Future[tuple[SNAPSHOT, int]]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class ParallelSolver[H: AbstractHull](_GenericScoreSolver[H, PolycubePlacement, _snapshots, _future_result]):
|
|
142
|
+
def __init__(
|
|
143
|
+
self,
|
|
144
|
+
grid: HullGrid[H],
|
|
145
|
+
polycubes: tuple[Polycube, ...],
|
|
146
|
+
scorer: Callable[[SNAPSHOT, SNAPSHOT, SNAPSHOT], tuple[SNAPSHOT, int]],
|
|
147
|
+
):
|
|
148
|
+
super().__init__(grid, polycubes)
|
|
149
|
+
self.scorer = scorer
|
|
150
|
+
|
|
151
|
+
max_workers = os.cpu_count() or 1
|
|
152
|
+
self.ex = ProcessPoolExecutor(max_workers=max_workers)
|
|
153
|
+
|
|
154
|
+
@abstractmethod
|
|
155
|
+
def next_candidates(self) -> Sequence[PolycubePlacement]:
|
|
156
|
+
pass
|
|
157
|
+
|
|
158
|
+
def score(self, candidate: _snapshots) -> _future_result:
|
|
159
|
+
return self.ex.submit(self.scorer, *candidate)
|
|
160
|
+
|
|
161
|
+
def _compute_best(self, candidates: Iterable[PolycubePlacement]) -> tuple[Placement | None, int]:
|
|
162
|
+
grid_snap = self.grid.to_snapshot()
|
|
163
|
+
hull_snap = self.grid.hull.to_snapshot()
|
|
164
|
+
|
|
165
|
+
futures: list[_future_result] = [self.score((hull_snap, grid_snap, p.to_snapshot())) for p in candidates]
|
|
166
|
+
best_score = -100000
|
|
167
|
+
best_p: SNAPSHOT | None = None
|
|
168
|
+
for fut in futures:
|
|
169
|
+
placement, score = fut.result()
|
|
170
|
+
if score > best_score:
|
|
171
|
+
best_score = score
|
|
172
|
+
best_p = placement
|
|
173
|
+
|
|
174
|
+
return Placement.from_snapshot(best_p) if best_p is not None else None, best_score
|
|
175
|
+
|
|
176
|
+
def done(self):
|
|
177
|
+
super().done()
|
|
178
|
+
self.ex.shutdown()
|