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/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
@@ -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
@@ -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()