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.
@@ -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__))
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -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)