gridfinity-rebase 0.1.2__tar.gz → 0.2.0__tar.gz

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.
Files changed (76) hide show
  1. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/PKG-INFO +4 -2
  2. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/README.md +3 -1
  3. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/pyproject.toml +1 -2
  4. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/pyproject.toml.orig +2 -2
  5. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/scripts/benchmark.py +13 -18
  6. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/src/gridfinity_rebase/api.py +40 -19
  7. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/src/gridfinity_rebase/geometry.py +116 -7
  8. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/src/gridfinity_rebase/stl.py +24 -2
  9. gridfinity_rebase-0.2.0/tests/test_normalization.py +282 -0
  10. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/uv.lock +1 -1
  11. gridfinity_rebase-0.1.2/docs/benchmark.json +0 -186
  12. gridfinity_rebase-0.1.2/docs/images/README.md +0 -47
  13. gridfinity_rebase-0.1.2/docs/images/base-magnet-holes-2x3.png +0 -0
  14. gridfinity_rebase-0.1.2/docs/images/base-plain-2x3.png +0 -0
  15. gridfinity_rebase-0.1.2/docs/images/base-press-fit-2x3.png +0 -0
  16. gridfinity_rebase-0.1.2/docs/images/base-press-fit-screws-2x3.png +0 -0
  17. gridfinity_rebase-0.1.2/docs/images/base-rebuilt-2x3.png +0 -0
  18. gridfinity_rebase-0.1.2/docs/images/base-rebuilt-only-corners-2x3.png +0 -0
  19. gridfinity_rebase-0.1.2/docs/images/base-rebuilt-screws-2x3.png +0 -0
  20. gridfinity_rebase-0.1.2/docs/images/base-rebuilt-screws-thumbscrew-2x3.png +0 -0
  21. gridfinity_rebase-0.1.2/docs/images/base-rebuilt-screws-thumbscrew-only-corners-2x3.png +0 -0
  22. gridfinity_rebase-0.1.2/docs/images/base-rebuilt-thumbscrew-2x3.png +0 -0
  23. gridfinity_rebase-0.1.2/docs/images/base-screws-2x3.png +0 -0
  24. gridfinity_rebase-0.1.2/docs/images/detail-crush-ribs.png +0 -0
  25. gridfinity_rebase-0.1.2/docs/images/detail-magnet-holes.png +0 -0
  26. gridfinity_rebase-0.1.2/docs/images/detail-segments-256.png +0 -0
  27. gridfinity_rebase-0.1.2/docs/images/detail-segments-32.png +0 -0
  28. gridfinity_rebase-0.1.2/docs/images/detail-segments-64.png +0 -0
  29. gridfinity_rebase-0.1.2/docs/images/detail-side-loading.png +0 -0
  30. gridfinity_rebase-0.1.2/docs/images/detail-thumbscrew.png +0 -0
  31. gridfinity_rebase-0.1.2/docs/images/thumbnails/base-magnet-holes-2x3.png +0 -0
  32. gridfinity_rebase-0.1.2/docs/images/thumbnails/base-plain-2x3.png +0 -0
  33. gridfinity_rebase-0.1.2/docs/images/thumbnails/base-press-fit-2x3.png +0 -0
  34. gridfinity_rebase-0.1.2/docs/images/thumbnails/base-press-fit-screws-2x3.png +0 -0
  35. gridfinity_rebase-0.1.2/docs/images/thumbnails/base-rebuilt-2x3.png +0 -0
  36. gridfinity_rebase-0.1.2/docs/images/thumbnails/base-rebuilt-only-corners-2x3.png +0 -0
  37. gridfinity_rebase-0.1.2/docs/images/thumbnails/base-rebuilt-screws-2x3.png +0 -0
  38. gridfinity_rebase-0.1.2/docs/images/thumbnails/base-rebuilt-screws-thumbscrew-2x3.png +0 -0
  39. gridfinity_rebase-0.1.2/docs/images/thumbnails/base-rebuilt-screws-thumbscrew-only-corners-2x3.png +0 -0
  40. gridfinity_rebase-0.1.2/docs/images/thumbnails/base-rebuilt-thumbscrew-2x3.png +0 -0
  41. gridfinity_rebase-0.1.2/docs/images/thumbnails/base-screws-2x3.png +0 -0
  42. gridfinity_rebase-0.1.2/docs/images/thumbnails/detail-crush-ribs.png +0 -0
  43. gridfinity_rebase-0.1.2/docs/images/thumbnails/detail-magnet-holes.png +0 -0
  44. gridfinity_rebase-0.1.2/docs/images/thumbnails/detail-segments-256.png +0 -0
  45. gridfinity_rebase-0.1.2/docs/images/thumbnails/detail-segments-32.png +0 -0
  46. gridfinity_rebase-0.1.2/docs/images/thumbnails/detail-segments-64.png +0 -0
  47. gridfinity_rebase-0.1.2/docs/images/thumbnails/detail-side-loading.png +0 -0
  48. gridfinity_rebase-0.1.2/docs/images/thumbnails/detail-thumbscrew.png +0 -0
  49. gridfinity_rebase-0.1.2/docs/performance.md +0 -94
  50. gridfinity_rebase-0.1.2/docs/presets.md +0 -163
  51. gridfinity_rebase-0.1.2/docs/segments.md +0 -46
  52. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/LICENSE +0 -0
  53. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/scripts/render_readme.py +0 -0
  54. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/scripts/setup-dev.sh +0 -0
  55. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/src/gridfinity_rebase/__init__.py +0 -0
  56. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/src/gridfinity_rebase/__main__.py +0 -0
  57. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/src/gridfinity_rebase/catalog.py +0 -0
  58. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/src/gridfinity_rebase/cli.py +0 -0
  59. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/src/gridfinity_rebase/errors.py +0 -0
  60. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/src/gridfinity_rebase/options.py +0 -0
  61. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/src/gridfinity_rebase/presets.py +0 -0
  62. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/src/gridfinity_rebase/py.typed +0 -0
  63. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/fixtures/README.md +0 -0
  64. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/fixtures/gf_bin_1x1_h6_bin_v01.stl +0 -0
  65. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/fixtures/test-rebased.stl +0 -0
  66. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/fixtures/test.stl +0 -0
  67. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/test_api.py +0 -0
  68. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/test_cli.py +0 -0
  69. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/test_core.py +0 -0
  70. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/test_only_corners.py +0 -0
  71. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/test_options.py +0 -0
  72. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/test_presets.py +0 -0
  73. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/test_side_pocket_height.py +0 -0
  74. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/test_standard_bins.py +0 -0
  75. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/test_thumbscrew.py +0 -0
  76. {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/test_validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gridfinity-rebase
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Fast, validated Gridfinity STL foot replacement
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -152,10 +152,12 @@ See [curve resolution](docs/segments.md) for `--segments` settings and visual co
152
152
  ## Key details
153
153
 
154
154
  - The CLI supports standard rectangular Gridfinity bins on a 42 mm grid. Use millimeters for STL units, with feet at the bottom and Z pointing up.
155
+ - Any rotation around Z is detected automatically, including for a separately rotated donor. Processing uses a common local frame: feet aligned with X/Y, the first foot centered at X=Y=0, and the underside at Z=0. The result returns to the input's original position and rotation. Models lying on their side or upside down are not automatically reoriented.
155
156
  - The CLI detects the base and cut height. If the feet are valid Gridfinity but the full base is not recognized, it replaces only the bottom 4.75 mm foot profile and prints a warning. Geometry above the cut is preserved, including any old holes. Check these results in a slicer. Invalid meshes, incompatible seams and disconnected replacement feet still fail validation.
156
157
  - A donor base can match the input grid or have its X/Y dimensions swapped. The CLI can also tile a 1×1 donor base across the input.
157
158
  - Unmodified presets write to `bin-rebased-PRESET.stl`. `--thumbscrew` and `--only-corners` append their names to the filename. Modified settings use a descriptive suffix, such as `bin-rebased-magnets-bottom-crush-ribs-screws.stl`. Donor bases use `bin-rebased.stl`. Use `-o` to choose a filename and `--overwrite` to replace an existing output.
158
159
  - JSON reports include the resolved `geometry` settings and a `warnings` array (empty when there are no warnings). With `--json`, warnings are included in the report instead of printed to stderr. `base_height_mode` is `auto` when the program chooses the cut height, or `manual` when `--base-height` is supplied. Automatic 4.75 mm foot replacement includes a warning. `base` identifies the starting preset or donor path. The default preset is `plain`. For donor bases, `geometry` is `null`.
160
+ - Inspection and rebase reports include `rotation_z_deg`, the local grid's rotation in the source coordinates, and `origin_mm`, its first foot's center at underside height in those same coordinates. Square feet determine rotation modulo 90°; the reported angle is in [-45°, 45°), so equivalent orientations can swap the reported grid's X/Y dimensions. Reported cut heights remain relative to the underside.
159
161
  - `--base-height MM` overrides automatic detection for custom models and replaces everything below that height. The minimum is 4.75 mm.
160
162
 
161
163
  Run `gridfinity-rebase --help` for all options.
@@ -176,7 +178,7 @@ for JSON output and environment details.
176
178
 
177
179
  Szymon here. I used AI to build this tool.
178
180
 
179
- I needed a way for quick replacement of base of custom Grdifnity bins downloaded from Internet, but I didn't want to spend too much of my free time on this. I collaborated with the AI, describing how it should work, how I want to use it and what's the important from the both code perspective and end-user perspective. It allowed me to iterate and create something that is exactly what I needed. Now I share it with you.
181
+ I needed a way to quickly replace the base of custom Gridfinity bins downloaded from the Internet, but I didn't want to spend too much of my free time on this. I collaborated with the AI, describing how it should work, how I want to use it and what's the important from the both code perspective and end-user perspective. It allowed me to iterate and create something that is exactly what I needed. Now I share it with you.
180
182
 
181
183
  Just want you to know.
182
184
 
@@ -141,10 +141,12 @@ See [curve resolution](docs/segments.md) for `--segments` settings and visual co
141
141
  ## Key details
142
142
 
143
143
  - The CLI supports standard rectangular Gridfinity bins on a 42 mm grid. Use millimeters for STL units, with feet at the bottom and Z pointing up.
144
+ - Any rotation around Z is detected automatically, including for a separately rotated donor. Processing uses a common local frame: feet aligned with X/Y, the first foot centered at X=Y=0, and the underside at Z=0. The result returns to the input's original position and rotation. Models lying on their side or upside down are not automatically reoriented.
144
145
  - The CLI detects the base and cut height. If the feet are valid Gridfinity but the full base is not recognized, it replaces only the bottom 4.75 mm foot profile and prints a warning. Geometry above the cut is preserved, including any old holes. Check these results in a slicer. Invalid meshes, incompatible seams and disconnected replacement feet still fail validation.
145
146
  - A donor base can match the input grid or have its X/Y dimensions swapped. The CLI can also tile a 1×1 donor base across the input.
146
147
  - Unmodified presets write to `bin-rebased-PRESET.stl`. `--thumbscrew` and `--only-corners` append their names to the filename. Modified settings use a descriptive suffix, such as `bin-rebased-magnets-bottom-crush-ribs-screws.stl`. Donor bases use `bin-rebased.stl`. Use `-o` to choose a filename and `--overwrite` to replace an existing output.
147
148
  - JSON reports include the resolved `geometry` settings and a `warnings` array (empty when there are no warnings). With `--json`, warnings are included in the report instead of printed to stderr. `base_height_mode` is `auto` when the program chooses the cut height, or `manual` when `--base-height` is supplied. Automatic 4.75 mm foot replacement includes a warning. `base` identifies the starting preset or donor path. The default preset is `plain`. For donor bases, `geometry` is `null`.
149
+ - Inspection and rebase reports include `rotation_z_deg`, the local grid's rotation in the source coordinates, and `origin_mm`, its first foot's center at underside height in those same coordinates. Square feet determine rotation modulo 90°; the reported angle is in [-45°, 45°), so equivalent orientations can swap the reported grid's X/Y dimensions. Reported cut heights remain relative to the underside.
148
150
  - `--base-height MM` overrides automatic detection for custom models and replaces everything below that height. The minimum is 4.75 mm.
149
151
 
150
152
  Run `gridfinity-rebase --help` for all options.
@@ -165,7 +167,7 @@ for JSON output and environment details.
165
167
 
166
168
  Szymon here. I used AI to build this tool.
167
169
 
168
- I needed a way for quick replacement of base of custom Grdifnity bins downloaded from Internet, but I didn't want to spend too much of my free time on this. I collaborated with the AI, describing how it should work, how I want to use it and what's the important from the both code perspective and end-user perspective. It allowed me to iterate and create something that is exactly what I needed. Now I share it with you.
170
+ I needed a way to quickly replace the base of custom Gridfinity bins downloaded from the Internet, but I didn't want to spend too much of my free time on this. I collaborated with the AI, describing how it should work, how I want to use it and what's the important from the both code perspective and end-user perspective. It allowed me to iterate and create something that is exactly what I needed. Now I share it with you.
169
171
 
170
172
  Just want you to know.
171
173
 
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "gridfinity-rebase"
7
- version = "0.1.2"
7
+ version = "0.2.0"
8
8
  description = "Fast, validated Gridfinity STL foot replacement"
9
9
  requires-python = ">=3.10"
10
10
  readme = "README.md"
@@ -33,7 +33,6 @@ testpaths = ["tests"]
33
33
  [tool.uv.build-backend]
34
34
  source-include = [
35
35
  "tests/**",
36
- "docs/**",
37
36
  "scripts/**",
38
37
  "uv.lock",
39
38
  ]
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "gridfinity-rebase"
7
- version = "0.1.2"
7
+ version = "0.2.0"
8
8
  description = "Fast, validated Gridfinity STL foot replacement"
9
9
  requires-python = ">=3.10"
10
10
  readme = "README.md"
@@ -28,4 +28,4 @@ dev = [
28
28
  testpaths = ["tests"]
29
29
 
30
30
  [tool.uv.build-backend]
31
- source-include = ["tests/**", "docs/**", "scripts/**", "uv.lock"]
31
+ source-include = ["tests/**", "scripts/**", "uv.lock"]
@@ -16,14 +16,15 @@ from time import perf_counter
16
16
 
17
17
  import manifold3d as m
18
18
 
19
- from gridfinity_rebase.geometry import (
20
- Grid,
21
- detect_grid,
22
- detect_standard_base,
23
- donor_base,
24
- replace_base,
25
- validate_height,
19
+ from gridfinity_rebase import (
20
+ DonorBase,
21
+ GeneratedBase,
22
+ load_stl,
23
+ rebase,
24
+ resolve_options,
25
+ save_stl,
26
26
  )
27
+ from gridfinity_rebase.geometry import Grid
27
28
  from gridfinity_rebase.presets import preset_base, rounded_prism
28
29
  from gridfinity_rebase.stl import read_stl, write_stl
29
30
 
@@ -42,19 +43,13 @@ def synthetic_bin(nx, ny):
42
43
 
43
44
 
44
45
  def run_engine(source, option, output):
45
- solid = read_stl(source)
46
- grid = detect_grid(solid)
47
- height = detect_standard_base(solid, grid).height
48
- validate_height(solid, grid, height)
46
+ model = load_stl(source)
49
47
  if option[0] == "--preset":
50
- base = preset_base(option[1], grid, height, segments=SEGMENTS)
48
+ base = GeneratedBase(resolve_options(option[1]), segments=SEGMENTS)
51
49
  else:
52
- other = read_stl(option[1])
53
- donor_grid = detect_grid(other)
54
- assert detect_standard_base(other, donor_grid).height >= height
55
- base = donor_base(other, donor_grid, grid, height)
56
- result = replace_base(solid, base, grid, height)
57
- write_stl(result, output, overwrite=True)
50
+ base = DonorBase(load_stl(option[1]))
51
+ result = rebase(model, base=base)
52
+ save_stl(result.model, output, overwrite=True)
58
53
 
59
54
 
60
55
  def main():
@@ -1,6 +1,8 @@
1
1
  """In-memory orchestration shared by CLI and other clients.
2
2
 
3
3
  All coordinates and heights are millimetres, with feet down and Z pointing up.
4
+ Operations normalize Z rotation and translation internally; meshes and report
5
+ origins retain source coordinates. Cut heights are relative to the underside.
4
6
  Functions are synchronous and never print or write files implicitly.
5
7
  """
6
8
 
@@ -19,10 +21,10 @@ from numpy.typing import NDArray
19
21
  from .errors import RebaseError
20
22
  from .geometry import (
21
23
  PROFILE_HEIGHT,
22
- Grid,
23
- detect_grid,
24
+ NormalizedModel,
24
25
  detect_standard_base,
25
26
  donor_base,
27
+ normalize_model,
26
28
  replace_base,
27
29
  validate_height,
28
30
  )
@@ -71,7 +73,11 @@ class Model:
71
73
 
72
74
  @dataclass(frozen=True)
73
75
  class Inspection:
74
- """Grid and standard-base detection; an unrecognized base is non-fatal."""
76
+ """Grid and base detection; an unrecognized base is non-fatal.
77
+
78
+ origin_mm and rotation_z_deg map the normalized grid back to source
79
+ coordinates. Rotation is modulo 90 degrees, in [-45, 45).
80
+ """
75
81
 
76
82
  grid: tuple[int, int]
77
83
  origin_mm: tuple[float, float, float]
@@ -82,12 +88,14 @@ class Inspection:
82
88
  nominal_base_height_mm: float | None = None
83
89
  base_detection_error: str | None = None
84
90
  base_detection_error_code: str | None = None
91
+ rotation_z_deg: float = 0.0
85
92
 
86
93
  def describe(self) -> dict:
87
94
  """Return a JSON-ready report, compatible with CLI inspection fields."""
88
95
  report = {
89
96
  "grid": list(self.grid),
90
97
  "origin_mm": list(self.origin_mm),
98
+ "rotation_z_deg": self.rotation_z_deg,
91
99
  "triangles": self.triangles,
92
100
  "standard_bin": self.standard_bin,
93
101
  }
@@ -169,12 +177,14 @@ class RebaseReport:
169
177
  elapsed_ms: float
170
178
  inspection: Inspection | None
171
179
  warnings: tuple[str, ...] = ()
180
+ rotation_z_deg: float = 0.0
172
181
 
173
182
  def describe(self) -> dict:
174
183
  report = self.inspection.describe() if self.inspection is not None else {}
175
184
  report.update(
176
185
  grid=list(self.grid),
177
186
  origin_mm=list(self.origin_mm),
187
+ rotation_z_deg=self.rotation_z_deg,
178
188
  base_height_mm=self.base_height_mm,
179
189
  base_height_mode=self.base_height_mode,
180
190
  geometry=self.geometry.describe() if self.geometry is not None else None,
@@ -215,21 +225,25 @@ def _require_model(model: Model) -> None:
215
225
  raise RebaseError("Expected a Model from load_stl()", code="invalid_model")
216
226
 
217
227
 
218
- def _grid(model: Model) -> Grid:
228
+ def _normalize(model: Model) -> NormalizedModel:
219
229
  with _geometry_errors("invalid_grid"):
220
- return detect_grid(model._solid, str(model.source) if model.source else "Model")
230
+ return normalize_model(
231
+ model._solid, str(model.source) if model.source else "Model"
232
+ )
221
233
 
222
234
 
223
- def _inspect(model: Model, grid: Grid) -> Inspection:
235
+ def _inspect(model: Model, normalized: NormalizedModel) -> Inspection:
236
+ grid = normalized.grid
224
237
  fields = {
225
238
  "grid": (grid.nx, grid.ny),
226
- "origin_mm": (grid.x, grid.y, grid.z),
239
+ "origin_mm": normalized.frame.origin_mm,
240
+ "rotation_z_deg": normalized.frame.rotation_z_deg,
227
241
  "triangles": model.triangles,
228
242
  }
229
243
  try:
230
244
  with _geometry_errors("unrecognized_base"):
231
245
  standard = detect_standard_base(
232
- model._solid, grid, str(model.source) if model.source else "Model"
246
+ normalized.solid, grid, str(model.source) if model.source else "Model"
233
247
  )
234
248
  except RebaseError as exc:
235
249
  if exc.code != "unrecognized_base":
@@ -255,9 +269,9 @@ def inspect_model(
255
269
  """Detect the grid and base; invalid grids raise, custom bases are reported."""
256
270
  _require_model(model)
257
271
  _notify(on_progress, "detecting_grid")
258
- grid = _grid(model)
272
+ normalized = _normalize(model)
259
273
  _notify(on_progress, "detecting_base")
260
- info = _inspect(model, grid)
274
+ info = _inspect(model, normalized)
261
275
  _notify(on_progress, "complete")
262
276
  return info
263
277
 
@@ -277,6 +291,9 @@ def rebase(
277
291
  absolute Z=0. Donor bases must match the grid, match after rotation, or
278
292
  be 1x1 for tiling.
279
293
 
294
+ Target and donor are normalized independently before geometry operations.
295
+ The returned model retains the target's original position and Z rotation.
296
+
280
297
  Callbacks run synchronously on the calling thread. Their exceptions
281
298
  propagate unchanged; callers are responsible for dispatching GUI updates.
282
299
  """
@@ -291,12 +308,13 @@ def rebase(
291
308
  ):
292
309
  raise RebaseError("Base height must be numeric", code="invalid_height")
293
310
  _notify(on_progress, "detecting_grid")
294
- grid = _grid(model)
311
+ normalized = _normalize(model)
312
+ grid = normalized.grid
295
313
  info = None
296
314
  warnings = ()
297
315
  if base_height_mm is None:
298
316
  _notify(on_progress, "detecting_base")
299
- info = _inspect(model, grid)
317
+ info = _inspect(model, normalized)
300
318
  if info.standard_bin:
301
319
  height = info.recommended_base_height_mm
302
320
  assert height is not None
@@ -313,7 +331,7 @@ def rebase(
313
331
  else:
314
332
  height = float(base_height_mm)
315
333
  with _geometry_errors("invalid_height", base_height_mm=height):
316
- validate_height(model._solid, grid, height)
334
+ validate_height(normalized.solid, grid, height)
317
335
 
318
336
  if isinstance(base, GeneratedBase):
319
337
  _notify(on_progress, "generating_base")
@@ -323,14 +341,14 @@ def rebase(
323
341
  else:
324
342
  _notify(on_progress, "preparing_donor")
325
343
  with _geometry_errors("incompatible_donor"):
326
- donor_grid = detect_grid(
344
+ donor = normalize_model(
327
345
  base.model._solid,
328
346
  str(base.model.source) if base.model.source else "Donor",
329
347
  )
330
348
  if base_height_mm is None and height > PROFILE_HEIGHT:
331
349
  donor_standard = detect_standard_base(
332
- base.model._solid,
333
- donor_grid,
350
+ donor.solid,
351
+ donor.grid,
334
352
  str(base.model.source) if base.model.source else "Donor",
335
353
  )
336
354
  if donor_standard.height < height - 0.001:
@@ -342,17 +360,20 @@ def rebase(
342
360
  "base_height_mm": height,
343
361
  },
344
362
  )
345
- replacement = donor_base(base.model._solid, donor_grid, grid, height)
363
+ replacement = donor_base(donor.solid, donor.grid, grid, height)
346
364
  geometry = None
347
365
 
348
366
  _notify(on_progress, "replacing_base")
349
367
  with _geometry_errors("replacement_failed"):
350
- solid = replace_base(model._solid, replacement, grid, height)
368
+ solid = normalized.frame.to_source(
369
+ replace_base(normalized.solid, replacement, grid, height)
370
+ )
351
371
  result = RebaseResult(
352
372
  model=Model._from_solid(solid),
353
373
  report=RebaseReport(
354
374
  grid=(grid.nx, grid.ny),
355
- origin_mm=(grid.x, grid.y, grid.z),
375
+ origin_mm=normalized.frame.origin_mm,
376
+ rotation_z_deg=normalized.frame.rotation_z_deg,
356
377
  base_height_mm=height,
357
378
  base_height_mode="auto" if base_height_mm is None else "manual",
358
379
  geometry=geometry,
@@ -92,7 +92,7 @@ def detect_grid(solid: m.Manifold, label="Model", tolerance=0.35) -> Grid:
92
92
  raise RebaseError(
93
93
  f"{label}: unsupported Gridfinity footprint: expected a complete rectangular grid"
94
94
  )
95
- expected_centres = sorted(map(tuple, centres))
95
+ expected_centres = dict(zip(map(tuple, lattice), centres))
96
96
  # Bounds alone would accept an ordinary 41.5 mm box or a baseplate.
97
97
  for height, width in [(0.3, 36.2), (1.4, 37.2), (3.5, 39.0), (4.6, 41.2)]:
98
98
  sections = outer_boxes(solid, z + height)
@@ -100,15 +100,20 @@ def detect_grid(solid: m.Manifold, label="Model", tolerance=0.35) -> Grid:
100
100
  raise RebaseError(
101
101
  f"{label}: not Gridfinity: unexpected foot sections at Z+{height:g} mm"
102
102
  )
103
- sections.sort(key=lambda box: tuple((box[0] + box[1]) / 2))
104
- for (low, high), centre in zip(sections, expected_centres):
103
+ seen = set()
104
+ for low, high in sections:
105
+ centre = (low + high) / 2
106
+ cell = tuple(np.rint((centre - origin) / PITCH).astype(int))
105
107
  if (
106
- np.max(np.abs(high - low - width)) > tolerance
107
- or np.max(np.abs((low + high) / 2 - centre)) > tolerance
108
+ cell not in expected_centres
109
+ or cell in seen
110
+ or np.max(np.abs(high - low - width)) > tolerance
111
+ or np.max(np.abs(centre - expected_centres[cell])) > tolerance
108
112
  ):
109
113
  raise RebaseError(
110
114
  f"{label}: not Gridfinity: incorrect chamfer/profile at Z+{height:g} mm; expected {width:g} mm feet (mm, Z up)"
111
115
  )
116
+ seen.add(cell)
112
117
  filled = m.CrossSection(
113
118
  [p for p in solid.slice(z + height).to_polygons() if signed_area(p) > 0]
114
119
  )
@@ -120,6 +125,99 @@ def detect_grid(solid: m.Manifold, label="Model", tolerance=0.35) -> Grid:
120
125
  return Grid(nx, ny, float(origin[0]), float(origin[1]), float(z))
121
126
 
122
127
 
128
+ @dataclass(frozen=True)
129
+ class ModelFrame:
130
+ """Rigid XY frame: first foot at the origin, underside at Z=0.
131
+
132
+ rotation_z_deg maps the local +X direction to the source coordinates.
133
+ Square feet only determine orientation modulo 90 degrees; use [-45, 45).
134
+ """
135
+
136
+ origin_mm: tuple[float, float, float]
137
+ rotation_z_deg: float
138
+
139
+ def to_local(self, solid: m.Manifold) -> m.Manifold:
140
+ return solid.translate(tuple(-v for v in self.origin_mm)).rotate(
141
+ (0, 0, -self.rotation_z_deg)
142
+ )
143
+
144
+ def to_source(self, solid: m.Manifold) -> m.Manifold:
145
+ return solid.rotate((0, 0, self.rotation_z_deg)).translate(self.origin_mm)
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class NormalizedModel:
150
+ """Validated Gridfinity geometry in a common frame for all operations."""
151
+
152
+ solid: m.Manifold
153
+ grid: Grid
154
+ frame: ModelFrame
155
+
156
+
157
+ def _foot_rotation(solid: m.Manifold, label: str) -> float:
158
+ # Simplification removes collinear slice/triangulation vertices before
159
+ # measuring edges. Use only outer contours, never hardware holes or the
160
+ # body's principal axes (which need not follow the feet).
161
+ polygons = [
162
+ np.asarray(p)
163
+ for p in solid.slice(1.4).simplify(0.0001).to_polygons()
164
+ if signed_area(p) > 1
165
+ ]
166
+ if not polygons:
167
+ raise RebaseError(
168
+ f"{label}: not Gridfinity: no feet found (use millimetres, Z up)"
169
+ )
170
+ edges = np.concatenate([np.roll(p, -1, axis=0) - p for p in polygons])
171
+ lengths = np.linalg.norm(edges, axis=1)
172
+ # Straight sides dominate a foot; short rounded-corner segments should
173
+ # not bias the estimate. Opposite/perpendicular sides share one phase
174
+ # after multiplying their angles by four.
175
+ longest = lengths >= lengths.max() * 0.5
176
+ angles = np.arctan2(edges[longest, 1], edges[longest, 0])
177
+ direction = np.sum(lengths[longest] * np.exp(4j * angles))
178
+ angle = (float(np.degrees(np.angle(direction)) / 4) + 45) % 90 - 45
179
+ # Stabilize the symmetric boundaries against float32 STL roundoff.
180
+ if abs(angle) < 0.00001:
181
+ return 0.0
182
+ if abs(abs(angle) - 45) < 0.00001:
183
+ return -45.0
184
+ return angle
185
+
186
+
187
+ def normalize_model(solid: m.Manifold, label="Model") -> NormalizedModel:
188
+ """Recover a Z rotation, validate the feet, and move to a local frame.
189
+
190
+ No scaling, mirroring or X/Y rotations are inferred. Angle estimation is
191
+ only a hypothesis: the full lattice and mating-profile checks must pass.
192
+ Centre before measuring to keep calculations independent of source offset.
193
+ """
194
+ bounds = solid.bounding_box()
195
+ if bounds[5] - bounds[2] < PROFILE_HEIGHT - 0.05:
196
+ raise RebaseError(
197
+ f"{label}: not Gridfinity: shorter than the 4.75 mm foot profile"
198
+ )
199
+ pivot = np.array(
200
+ [(bounds[0] + bounds[3]) / 2, (bounds[1] + bounds[4]) / 2, bounds[2]]
201
+ )
202
+ centred = solid.translate(tuple(-pivot))
203
+ angle = _foot_rotation(centred, label)
204
+ aligned = centred.rotate((0, 0, -angle))
205
+ detected = detect_grid(aligned, label)
206
+ origin = np.array([detected.x, detected.y, 0.0])
207
+ radians = np.radians(angle)
208
+ source_origin = (
209
+ origin[0] * np.cos(radians) - origin[1] * np.sin(radians) + pivot[0],
210
+ origin[0] * np.sin(radians) + origin[1] * np.cos(radians) + pivot[1],
211
+ pivot[2],
212
+ )
213
+ frame = ModelFrame(tuple(float(v) for v in source_origin), angle)
214
+ return NormalizedModel(
215
+ aligned.translate(tuple(-origin)),
216
+ Grid(detected.nx, detected.ny, 0.0, 0.0, 0.0),
217
+ frame,
218
+ )
219
+
220
+
123
221
  def lower(solid, height):
124
222
  return solid.trim_by_plane((0, 0, -1), -height)
125
223
 
@@ -244,8 +342,19 @@ def donor_base(solid, donor: Grid, target: Grid, height: float):
244
342
  # Tile feet and hardware cutouts, then join them with a continuous
245
343
  # standard bridge; copying whole 1x1 tiles would leave 0.5 mm slots.
246
344
  feet = lower(base, PROFILE_HEIGHT)
247
- donor_envelope = rounded_prism(41.5, 41.5, 3.75, PROFILE_HEIGHT, height)
248
- cutouts = donor_envelope - base.trim_by_plane((0, 0, 1), PROFILE_HEIGHT)
345
+ donor_bridge = base.trim_by_plane((0, 0, 1), PROFILE_HEIGHT)
346
+ # Derive the envelope from the donor's own vertices. Subtracting a
347
+ # separately tessellated nominal outline also copies tiny exterior
348
+ # slivers caused by STL quantization/rotation as if they were holes.
349
+ cutouts = donor_bridge.hull() - donor_bridge
350
+ holes = []
351
+ for part in cutouts.decompose():
352
+ points = np.asarray(part.to_mesh64().vert_properties)[:, :3]
353
+ # Hull subtraction can leave exactly coplanar shells in any
354
+ # direction. Keep every volumetric part, regardless of its size.
355
+ if np.linalg.matrix_rank(points - points[0]) == 3:
356
+ holes.append(part)
357
+ cutouts = m.Manifold.batch_boolean(holes, m.OpType.Add)
249
358
  bridge = rounded_prism(
250
359
  target.nx * PITCH - 0.5,
251
360
  target.ny * PITCH - 0.5,
@@ -85,10 +85,32 @@ def write_stl(solid: m.Manifold, path: str | Path, *, overwrite: bool = False) -
85
85
  mesh = quantized.to_mesh()
86
86
  triangles = np.asarray(mesh.vert_properties)[:, :3][mesh.tri_verts]
87
87
  normals = np.cross(
88
- triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0]
88
+ np.subtract(triangles[:, 1], triangles[:, 0], dtype=np.float64),
89
+ np.subtract(triangles[:, 2], triangles[:, 0], dtype=np.float64),
89
90
  )
91
+ if np.any(np.all(normals == 0, axis=1)):
92
+ # Float32 can make a previously non-collinear triangle flat. Only
93
+ # in that case simplify at the engine's existing tolerance; leave
94
+ # ordinary exports alone and never raise tolerance to force a repair.
95
+ # STL has no face ancestry; clearing boolean face boundaries lets
96
+ # the engine collapse flat triangles across those boundaries too.
97
+ quantized = quantized.as_original().simplify(0)
98
+ if quantized.status() != m.Error.NoError or quantized.is_empty():
99
+ raise RebaseError(
100
+ "Cannot collapse degenerate float32 STL faces", code="export_failed"
101
+ )
102
+ mesh = quantized.to_mesh()
103
+ triangles = np.asarray(mesh.vert_properties)[:, :3][mesh.tri_verts]
104
+ normals = np.cross(
105
+ np.subtract(triangles[:, 1], triangles[:, 0], dtype=np.float64),
106
+ np.subtract(triangles[:, 2], triangles[:, 0], dtype=np.float64),
107
+ )
90
108
  lengths = np.linalg.norm(normals, axis=1)
91
- normals /= np.where(lengths > 0, lengths, 1)[:, None]
109
+ if np.any(lengths == 0):
110
+ raise RebaseError(
111
+ "Cannot export degenerate float32 STL faces", code="export_failed"
112
+ )
113
+ normals /= lengths[:, None]
92
114
  records = np.zeros(len(triangles), dtype=FACET)
93
115
  records["vertices"] = triangles
94
116
  records["normal"] = normals