gridfinity-rebase 0.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.
- gridfinity_rebase/__init__.py +78 -0
- gridfinity_rebase/__main__.py +5 -0
- gridfinity_rebase/api.py +363 -0
- gridfinity_rebase/catalog.py +86 -0
- gridfinity_rebase/cli.py +377 -0
- gridfinity_rebase/errors.py +19 -0
- gridfinity_rebase/geometry.py +328 -0
- gridfinity_rebase/options.py +97 -0
- gridfinity_rebase/presets.py +253 -0
- gridfinity_rebase/py.typed +0 -0
- gridfinity_rebase/stl.py +127 -0
- gridfinity_rebase-0.1.0.dist-info/METADATA +172 -0
- gridfinity_rebase-0.1.0.dist-info/RECORD +16 -0
- gridfinity_rebase-0.1.0.dist-info/WHEEL +4 -0
- gridfinity_rebase-0.1.0.dist-info/entry_points.txt +3 -0
- gridfinity_rebase-0.1.0.dist-info/licenses/LICENSE +23 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Public library API for replacing the base of a Gridfinity bin STL.
|
|
2
|
+
|
|
3
|
+
Geometry imports are lazy: presets and options do not load the native engine.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from importlib import import_module
|
|
7
|
+
from importlib.metadata import version
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from .catalog import Preset, list_presets, resolve_options
|
|
11
|
+
from .errors import RebaseError
|
|
12
|
+
from .options import BaseOptions
|
|
13
|
+
|
|
14
|
+
__version__ = version("gridfinity-rebase")
|
|
15
|
+
|
|
16
|
+
_API_EXPORTS = (
|
|
17
|
+
"Model",
|
|
18
|
+
"Inspection",
|
|
19
|
+
"GeneratedBase",
|
|
20
|
+
"DonorBase",
|
|
21
|
+
"ProgressEvent",
|
|
22
|
+
"ProgressStage",
|
|
23
|
+
"RebaseReport",
|
|
24
|
+
"RebaseResult",
|
|
25
|
+
"load_stl",
|
|
26
|
+
"inspect_model",
|
|
27
|
+
"rebase",
|
|
28
|
+
"save_stl",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"BaseOptions",
|
|
33
|
+
"DonorBase",
|
|
34
|
+
"GeneratedBase",
|
|
35
|
+
"Inspection",
|
|
36
|
+
"Model",
|
|
37
|
+
"Preset",
|
|
38
|
+
"ProgressEvent",
|
|
39
|
+
"ProgressStage",
|
|
40
|
+
"RebaseError",
|
|
41
|
+
"RebaseReport",
|
|
42
|
+
"RebaseResult",
|
|
43
|
+
"__version__",
|
|
44
|
+
"inspect_model",
|
|
45
|
+
"list_presets",
|
|
46
|
+
"load_stl",
|
|
47
|
+
"rebase",
|
|
48
|
+
"resolve_options",
|
|
49
|
+
"save_stl",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
if TYPE_CHECKING:
|
|
53
|
+
from .api import (
|
|
54
|
+
DonorBase,
|
|
55
|
+
GeneratedBase,
|
|
56
|
+
Inspection,
|
|
57
|
+
Model,
|
|
58
|
+
ProgressEvent,
|
|
59
|
+
ProgressStage,
|
|
60
|
+
RebaseReport,
|
|
61
|
+
RebaseResult,
|
|
62
|
+
inspect_model,
|
|
63
|
+
load_stl,
|
|
64
|
+
rebase,
|
|
65
|
+
save_stl,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def __getattr__(name: str):
|
|
70
|
+
if name in _API_EXPORTS:
|
|
71
|
+
api = import_module(".api", __name__)
|
|
72
|
+
globals().update({export: getattr(api, export) for export in _API_EXPORTS})
|
|
73
|
+
return globals()[name]
|
|
74
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def __dir__():
|
|
78
|
+
return sorted(set(globals()) | set(__all__))
|
gridfinity_rebase/api.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""In-memory orchestration shared by CLI and other clients.
|
|
2
|
+
|
|
3
|
+
All coordinates and heights are millimetres, with feet down and Z pointing up.
|
|
4
|
+
Functions are synchronous and never print or write files implicitly.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Callable, Iterator
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from numbers import Real
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from time import perf_counter
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
import manifold3d as m
|
|
16
|
+
import numpy as np
|
|
17
|
+
from numpy.typing import NDArray
|
|
18
|
+
|
|
19
|
+
from .errors import RebaseError
|
|
20
|
+
from .geometry import (
|
|
21
|
+
Grid,
|
|
22
|
+
detect_grid,
|
|
23
|
+
detect_standard_base,
|
|
24
|
+
donor_base,
|
|
25
|
+
replace_base,
|
|
26
|
+
validate_height,
|
|
27
|
+
)
|
|
28
|
+
from .options import BaseOptions
|
|
29
|
+
from .presets import generate_base
|
|
30
|
+
from .stl import read_stl, write_stl
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, init=False)
|
|
34
|
+
class Model:
|
|
35
|
+
"""Validated solid. Obtain through load_stl() or RebaseResult.model.
|
|
36
|
+
|
|
37
|
+
The engine representation is private. to_mesh() returns independent arrays
|
|
38
|
+
suitable for rendering, without exposing the geometry engine to callers.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
_solid: m.Manifold = field(repr=False)
|
|
42
|
+
source: Path | None
|
|
43
|
+
|
|
44
|
+
def __new__(cls):
|
|
45
|
+
raise TypeError("Models are created through load_stl() or rebase()")
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def _from_solid(cls, solid: m.Manifold, source: Path | None = None) -> "Model":
|
|
49
|
+
model = object.__new__(cls)
|
|
50
|
+
object.__setattr__(model, "_solid", solid)
|
|
51
|
+
object.__setattr__(model, "source", source)
|
|
52
|
+
return model
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def triangles(self) -> int:
|
|
56
|
+
return self._solid.num_tri()
|
|
57
|
+
|
|
58
|
+
def to_mesh(self) -> tuple[NDArray[np.float64], NDArray[np.uint64]]:
|
|
59
|
+
"""Return copies of vertices (N, 3) in mm and triangle indices (M, 3).
|
|
60
|
+
|
|
61
|
+
Coordinates retain the input origin and use float64 precision. STL
|
|
62
|
+
export separately quantizes and validates the result at float32.
|
|
63
|
+
"""
|
|
64
|
+
mesh = self._solid.to_mesh64()
|
|
65
|
+
return (
|
|
66
|
+
np.array(mesh.vert_properties[:, :3], dtype=np.float64, copy=True),
|
|
67
|
+
np.array(mesh.tri_verts, dtype=np.uint64, copy=True),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class Inspection:
|
|
73
|
+
"""Grid and standard-base detection; an unrecognized base is non-fatal."""
|
|
74
|
+
|
|
75
|
+
grid: tuple[int, int]
|
|
76
|
+
origin_mm: tuple[float, float, float]
|
|
77
|
+
triangles: int
|
|
78
|
+
standard_bin: bool
|
|
79
|
+
recommended_base_height_mm: float | None = None
|
|
80
|
+
interior_start_mm: float | None = None
|
|
81
|
+
nominal_base_height_mm: float | None = None
|
|
82
|
+
base_detection_error: str | None = None
|
|
83
|
+
base_detection_error_code: str | None = None
|
|
84
|
+
|
|
85
|
+
def describe(self) -> dict:
|
|
86
|
+
"""Return a JSON-ready report, compatible with CLI inspection fields."""
|
|
87
|
+
report = {
|
|
88
|
+
"grid": list(self.grid),
|
|
89
|
+
"origin_mm": list(self.origin_mm),
|
|
90
|
+
"triangles": self.triangles,
|
|
91
|
+
"standard_bin": self.standard_bin,
|
|
92
|
+
}
|
|
93
|
+
if self.standard_bin:
|
|
94
|
+
report.update(
|
|
95
|
+
recommended_base_height_mm=self.recommended_base_height_mm,
|
|
96
|
+
interior_start_mm=self.interior_start_mm,
|
|
97
|
+
nominal_base_height_mm=self.nominal_base_height_mm,
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
report["base_detection_error"] = self.base_detection_error
|
|
101
|
+
return report
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class GeneratedBase:
|
|
106
|
+
"""Generate feet using resolved options and a curve segment count."""
|
|
107
|
+
|
|
108
|
+
options: BaseOptions = field(default_factory=BaseOptions)
|
|
109
|
+
segments: int = 64
|
|
110
|
+
|
|
111
|
+
def __post_init__(self) -> None:
|
|
112
|
+
if not isinstance(self.options, BaseOptions):
|
|
113
|
+
raise RebaseError("options must be BaseOptions", code="invalid_options")
|
|
114
|
+
if (
|
|
115
|
+
not isinstance(self.segments, int)
|
|
116
|
+
or isinstance(self.segments, bool)
|
|
117
|
+
or not 32 <= self.segments <= 256
|
|
118
|
+
or self.segments % 8
|
|
119
|
+
):
|
|
120
|
+
raise RebaseError(
|
|
121
|
+
"Segments must be a multiple of 8 between 32 and 256",
|
|
122
|
+
code="invalid_segments",
|
|
123
|
+
details={"segments": self.segments},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True)
|
|
128
|
+
class DonorBase:
|
|
129
|
+
"""Copy an existing model's base, including its hardware geometry."""
|
|
130
|
+
|
|
131
|
+
model: Model
|
|
132
|
+
|
|
133
|
+
def __post_init__(self) -> None:
|
|
134
|
+
if not isinstance(self.model, Model):
|
|
135
|
+
raise RebaseError("Donor must be a Model", code="invalid_model")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
ProgressStage = Literal[
|
|
139
|
+
"detecting_grid",
|
|
140
|
+
"detecting_base",
|
|
141
|
+
"generating_base",
|
|
142
|
+
"preparing_donor",
|
|
143
|
+
"replacing_base",
|
|
144
|
+
"complete",
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True)
|
|
149
|
+
class ProgressEvent:
|
|
150
|
+
"""Stage notification, with no estimate of percentage or remaining time."""
|
|
151
|
+
|
|
152
|
+
stage: ProgressStage
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
ProgressCallback = Callable[[ProgressEvent], None]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class RebaseReport:
|
|
160
|
+
"""Resolved settings and geometry statistics, independent of file output."""
|
|
161
|
+
|
|
162
|
+
grid: tuple[int, int]
|
|
163
|
+
origin_mm: tuple[float, float, float]
|
|
164
|
+
base_height_mm: float
|
|
165
|
+
base_height_mode: Literal["auto", "manual"]
|
|
166
|
+
geometry: BaseOptions | None
|
|
167
|
+
triangles: int
|
|
168
|
+
elapsed_ms: float
|
|
169
|
+
inspection: Inspection | None
|
|
170
|
+
|
|
171
|
+
def describe(self) -> dict:
|
|
172
|
+
report = self.inspection.describe() if self.inspection is not None else {}
|
|
173
|
+
report.update(
|
|
174
|
+
grid=list(self.grid),
|
|
175
|
+
origin_mm=list(self.origin_mm),
|
|
176
|
+
base_height_mm=self.base_height_mm,
|
|
177
|
+
base_height_mode=self.base_height_mode,
|
|
178
|
+
geometry=self.geometry.describe() if self.geometry is not None else None,
|
|
179
|
+
thumbscrew=self.geometry.thumbscrew if self.geometry is not None else None,
|
|
180
|
+
triangles=self.triangles,
|
|
181
|
+
elapsed_ms=self.elapsed_ms,
|
|
182
|
+
)
|
|
183
|
+
return report
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@dataclass(frozen=True)
|
|
187
|
+
class RebaseResult:
|
|
188
|
+
model: Model
|
|
189
|
+
report: RebaseReport
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@contextmanager
|
|
193
|
+
def _geometry_errors(code: str, **details) -> Iterator[None]:
|
|
194
|
+
"""Give legacy geometry errors a code without interpreting their messages."""
|
|
195
|
+
try:
|
|
196
|
+
yield
|
|
197
|
+
except RebaseError as exc:
|
|
198
|
+
if exc.code != "invalid_geometry":
|
|
199
|
+
raise
|
|
200
|
+
raise RebaseError(
|
|
201
|
+
str(exc), code=code, details={**exc.details, **details}
|
|
202
|
+
) from exc
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _notify(callback: ProgressCallback | None, stage: ProgressStage) -> None:
|
|
206
|
+
if callback is not None:
|
|
207
|
+
callback(ProgressEvent(stage))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _require_model(model: Model) -> None:
|
|
211
|
+
if not isinstance(model, Model):
|
|
212
|
+
raise RebaseError("Expected a Model from load_stl()", code="invalid_model")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _grid(model: Model) -> Grid:
|
|
216
|
+
with _geometry_errors("invalid_grid"):
|
|
217
|
+
return detect_grid(model._solid, str(model.source) if model.source else "Model")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _inspect(model: Model, grid: Grid) -> Inspection:
|
|
221
|
+
fields = {
|
|
222
|
+
"grid": (grid.nx, grid.ny),
|
|
223
|
+
"origin_mm": (grid.x, grid.y, grid.z),
|
|
224
|
+
"triangles": model.triangles,
|
|
225
|
+
}
|
|
226
|
+
try:
|
|
227
|
+
with _geometry_errors("unrecognized_base"):
|
|
228
|
+
standard = detect_standard_base(
|
|
229
|
+
model._solid, grid, str(model.source) if model.source else "Model"
|
|
230
|
+
)
|
|
231
|
+
except RebaseError as exc:
|
|
232
|
+
if exc.code != "unrecognized_base":
|
|
233
|
+
raise
|
|
234
|
+
return Inspection(
|
|
235
|
+
**fields,
|
|
236
|
+
standard_bin=False,
|
|
237
|
+
base_detection_error=str(exc),
|
|
238
|
+
base_detection_error_code=exc.code,
|
|
239
|
+
)
|
|
240
|
+
return Inspection(**fields, **standard.describe())
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def load_stl(path: str | Path) -> Model:
|
|
244
|
+
"""Read a closed, consistently oriented STL solid without detecting its grid."""
|
|
245
|
+
path = Path(path)
|
|
246
|
+
return Model._from_solid(read_stl(path), path)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def inspect_model(
|
|
250
|
+
model: Model, *, on_progress: ProgressCallback | None = None
|
|
251
|
+
) -> Inspection:
|
|
252
|
+
"""Detect the grid and base; invalid grids raise, custom bases are reported."""
|
|
253
|
+
_require_model(model)
|
|
254
|
+
_notify(on_progress, "detecting_grid")
|
|
255
|
+
grid = _grid(model)
|
|
256
|
+
_notify(on_progress, "detecting_base")
|
|
257
|
+
info = _inspect(model, grid)
|
|
258
|
+
_notify(on_progress, "complete")
|
|
259
|
+
return info
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def rebase(
|
|
263
|
+
model: Model,
|
|
264
|
+
*,
|
|
265
|
+
base: GeneratedBase | DonorBase,
|
|
266
|
+
base_height_mm: float | None = None,
|
|
267
|
+
on_progress: ProgressCallback | None = None,
|
|
268
|
+
) -> RebaseResult:
|
|
269
|
+
"""Return a new model, preserving the input above the validated cut.
|
|
270
|
+
|
|
271
|
+
None selects automatic standard-base detection. An explicit height is
|
|
272
|
+
measured from the bottom of the feet, not from absolute Z=0. Donor bases
|
|
273
|
+
must match the grid, match after rotation, or be 1x1 for tiling.
|
|
274
|
+
|
|
275
|
+
Callbacks run synchronously on the calling thread. Their exceptions
|
|
276
|
+
propagate unchanged; callers are responsible for dispatching GUI updates.
|
|
277
|
+
"""
|
|
278
|
+
start = perf_counter()
|
|
279
|
+
_require_model(model)
|
|
280
|
+
if not isinstance(base, (GeneratedBase, DonorBase)):
|
|
281
|
+
raise RebaseError(
|
|
282
|
+
"base must be GeneratedBase or DonorBase", code="invalid_base"
|
|
283
|
+
)
|
|
284
|
+
if base_height_mm is not None and (
|
|
285
|
+
not isinstance(base_height_mm, Real) or isinstance(base_height_mm, bool)
|
|
286
|
+
):
|
|
287
|
+
raise RebaseError("Base height must be numeric", code="invalid_height")
|
|
288
|
+
_notify(on_progress, "detecting_grid")
|
|
289
|
+
grid = _grid(model)
|
|
290
|
+
info = None
|
|
291
|
+
if base_height_mm is None:
|
|
292
|
+
_notify(on_progress, "detecting_base")
|
|
293
|
+
info = _inspect(model, grid)
|
|
294
|
+
if not info.standard_bin:
|
|
295
|
+
raise RebaseError(
|
|
296
|
+
info.base_detection_error or "Standard base not recognized",
|
|
297
|
+
code="unrecognized_base",
|
|
298
|
+
)
|
|
299
|
+
height = info.recommended_base_height_mm
|
|
300
|
+
assert height is not None
|
|
301
|
+
else:
|
|
302
|
+
height = float(base_height_mm)
|
|
303
|
+
with _geometry_errors("invalid_height", base_height_mm=height):
|
|
304
|
+
validate_height(model._solid, grid, height)
|
|
305
|
+
|
|
306
|
+
if isinstance(base, GeneratedBase):
|
|
307
|
+
_notify(on_progress, "generating_base")
|
|
308
|
+
with _geometry_errors("base_generation_failed"):
|
|
309
|
+
replacement = generate_base(base.options, grid, height, base.segments)
|
|
310
|
+
geometry = base.options
|
|
311
|
+
else:
|
|
312
|
+
_notify(on_progress, "preparing_donor")
|
|
313
|
+
with _geometry_errors("incompatible_donor"):
|
|
314
|
+
donor_grid = detect_grid(
|
|
315
|
+
base.model._solid,
|
|
316
|
+
str(base.model.source) if base.model.source else "Donor",
|
|
317
|
+
)
|
|
318
|
+
if base_height_mm is None:
|
|
319
|
+
donor_standard = detect_standard_base(
|
|
320
|
+
base.model._solid,
|
|
321
|
+
donor_grid,
|
|
322
|
+
str(base.model.source) if base.model.source else "Donor",
|
|
323
|
+
)
|
|
324
|
+
if donor_standard.height < height - 0.001:
|
|
325
|
+
raise RebaseError(
|
|
326
|
+
f"Donor base ends at {donor_standard.height:g} mm, before the input's {height:g} mm cut; use a compatible donor or set --base-height explicitly",
|
|
327
|
+
code="incompatible_donor",
|
|
328
|
+
details={
|
|
329
|
+
"donor_base_height_mm": donor_standard.height,
|
|
330
|
+
"base_height_mm": height,
|
|
331
|
+
},
|
|
332
|
+
)
|
|
333
|
+
replacement = donor_base(base.model._solid, donor_grid, grid, height)
|
|
334
|
+
geometry = None
|
|
335
|
+
|
|
336
|
+
_notify(on_progress, "replacing_base")
|
|
337
|
+
with _geometry_errors("replacement_failed"):
|
|
338
|
+
solid = replace_base(model._solid, replacement, grid, height)
|
|
339
|
+
result = RebaseResult(
|
|
340
|
+
model=Model._from_solid(solid),
|
|
341
|
+
report=RebaseReport(
|
|
342
|
+
grid=(grid.nx, grid.ny),
|
|
343
|
+
origin_mm=(grid.x, grid.y, grid.z),
|
|
344
|
+
base_height_mm=height,
|
|
345
|
+
base_height_mode="auto" if base_height_mm is None else "manual",
|
|
346
|
+
geometry=geometry,
|
|
347
|
+
triangles=solid.num_tri(),
|
|
348
|
+
elapsed_ms=round((perf_counter() - start) * 1000, 2),
|
|
349
|
+
inspection=info,
|
|
350
|
+
),
|
|
351
|
+
)
|
|
352
|
+
_notify(on_progress, "complete")
|
|
353
|
+
return result
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def save_stl(model: Model, path: str | Path, *, overwrite: bool = False) -> int:
|
|
357
|
+
"""Atomically export binary STL; return the exported triangle count.
|
|
358
|
+
|
|
359
|
+
Float32 export is validated separately and may change the triangle count
|
|
360
|
+
compared to the in-memory model. Existing files are protected by default.
|
|
361
|
+
"""
|
|
362
|
+
_require_model(model)
|
|
363
|
+
return write_stl(model._solid, path, overwrite=overwrite)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Preset descriptions, kept importable without loading the geometry engine."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, replace
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
from .errors import RebaseError
|
|
7
|
+
from .options import BaseOptions
|
|
8
|
+
|
|
9
|
+
PRESETS = {
|
|
10
|
+
"plain": "Plain mating foot, no magnet or screw holes",
|
|
11
|
+
"magnet-holes": "Smooth bottom-loading 6.5 x 2.4 mm magnet pockets, no crush ribs",
|
|
12
|
+
"rebuilt": "Rebuilt/Refined fan pattern of side-loading 5.86 x 2.1 mm magnet pockets and removal slots",
|
|
13
|
+
"press-fit": "Rebuilt bottom magnet pockets with 8 crush ribs (5.9–6.5 mm diameter)",
|
|
14
|
+
"refined": "Alias of rebuilt: side-loading magnet pockets",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
ALIASES = {"refined": "rebuilt"}
|
|
18
|
+
|
|
19
|
+
PRESET_OPTIONS = {
|
|
20
|
+
"plain": BaseOptions(),
|
|
21
|
+
"magnet-holes": BaseOptions(magnets="bottom"),
|
|
22
|
+
"rebuilt": BaseOptions(magnets="side"),
|
|
23
|
+
"press-fit": BaseOptions(magnets="bottom", crush_ribs=True),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Preset:
|
|
29
|
+
"""Preset metadata; aliases have the same options as their canonical preset."""
|
|
30
|
+
|
|
31
|
+
id: str
|
|
32
|
+
description: str
|
|
33
|
+
options: BaseOptions
|
|
34
|
+
alias_of: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def list_presets() -> tuple[Preset, ...]:
|
|
38
|
+
"""Return presets and compatibility aliases without loading geometry."""
|
|
39
|
+
return tuple(
|
|
40
|
+
Preset(
|
|
41
|
+
name,
|
|
42
|
+
description,
|
|
43
|
+
PRESET_OPTIONS[ALIASES.get(name, name)],
|
|
44
|
+
ALIASES.get(name),
|
|
45
|
+
)
|
|
46
|
+
for name, description in PRESETS.items()
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def resolve_options(
|
|
51
|
+
preset: str = "plain",
|
|
52
|
+
*,
|
|
53
|
+
magnets: Literal["none", "side", "bottom"] | None = None,
|
|
54
|
+
screws: bool | None = None,
|
|
55
|
+
crush_ribs: bool | None = None,
|
|
56
|
+
thumbscrew: bool | None = None,
|
|
57
|
+
only_corners: bool | None = None,
|
|
58
|
+
side_pocket_height_mm: float | None = None,
|
|
59
|
+
) -> BaseOptions:
|
|
60
|
+
"""Apply explicit overrides to a preset; None means keep its setting."""
|
|
61
|
+
if preset not in PRESETS:
|
|
62
|
+
raise RebaseError(
|
|
63
|
+
f"Unknown preset: {preset}",
|
|
64
|
+
code="unknown_preset",
|
|
65
|
+
details={"preset": preset},
|
|
66
|
+
)
|
|
67
|
+
options = PRESET_OPTIONS[ALIASES.get(preset, preset)]
|
|
68
|
+
overrides = {
|
|
69
|
+
name: value
|
|
70
|
+
for name, value in {
|
|
71
|
+
"magnets": magnets,
|
|
72
|
+
"screws": screws,
|
|
73
|
+
"crush_ribs": crush_ribs,
|
|
74
|
+
"thumbscrew": thumbscrew,
|
|
75
|
+
"only_corners": only_corners,
|
|
76
|
+
"side_pocket_height_mm": side_pocket_height_mm,
|
|
77
|
+
}.items()
|
|
78
|
+
if value is not None
|
|
79
|
+
}
|
|
80
|
+
# Ribs inherited from a preset belong to its bottom-loading pockets.
|
|
81
|
+
# An explicit --crush-ribs still fails validation for other magnet styles.
|
|
82
|
+
if magnets is not None and magnets != "bottom" and crush_ribs is None:
|
|
83
|
+
overrides["crush_ribs"] = False
|
|
84
|
+
if magnets is not None and magnets != "side" and side_pocket_height_mm is None:
|
|
85
|
+
overrides["side_pocket_height_mm"] = None
|
|
86
|
+
return replace(options, **overrides)
|