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.
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/PKG-INFO +4 -2
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/README.md +3 -1
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/pyproject.toml +1 -2
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/pyproject.toml.orig +2 -2
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/scripts/benchmark.py +13 -18
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/src/gridfinity_rebase/api.py +40 -19
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/src/gridfinity_rebase/geometry.py +116 -7
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/src/gridfinity_rebase/stl.py +24 -2
- gridfinity_rebase-0.2.0/tests/test_normalization.py +282 -0
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/uv.lock +1 -1
- gridfinity_rebase-0.1.2/docs/benchmark.json +0 -186
- gridfinity_rebase-0.1.2/docs/images/README.md +0 -47
- gridfinity_rebase-0.1.2/docs/images/base-magnet-holes-2x3.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/base-plain-2x3.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/base-press-fit-2x3.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/base-press-fit-screws-2x3.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/base-rebuilt-2x3.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/base-rebuilt-only-corners-2x3.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/base-rebuilt-screws-2x3.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/base-rebuilt-screws-thumbscrew-2x3.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/base-rebuilt-screws-thumbscrew-only-corners-2x3.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/base-rebuilt-thumbscrew-2x3.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/base-screws-2x3.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/detail-crush-ribs.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/detail-magnet-holes.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/detail-segments-256.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/detail-segments-32.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/detail-segments-64.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/detail-side-loading.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/detail-thumbscrew.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/thumbnails/base-magnet-holes-2x3.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/thumbnails/base-plain-2x3.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/thumbnails/base-press-fit-2x3.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/thumbnails/base-press-fit-screws-2x3.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/thumbnails/base-rebuilt-2x3.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/thumbnails/base-rebuilt-only-corners-2x3.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/thumbnails/base-rebuilt-screws-2x3.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/thumbnails/base-rebuilt-screws-thumbscrew-2x3.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/thumbnails/base-rebuilt-screws-thumbscrew-only-corners-2x3.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/thumbnails/base-rebuilt-thumbscrew-2x3.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/thumbnails/base-screws-2x3.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/thumbnails/detail-crush-ribs.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/thumbnails/detail-magnet-holes.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/thumbnails/detail-segments-256.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/thumbnails/detail-segments-32.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/thumbnails/detail-segments-64.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/thumbnails/detail-side-loading.png +0 -0
- gridfinity_rebase-0.1.2/docs/images/thumbnails/detail-thumbscrew.png +0 -0
- gridfinity_rebase-0.1.2/docs/performance.md +0 -94
- gridfinity_rebase-0.1.2/docs/presets.md +0 -163
- gridfinity_rebase-0.1.2/docs/segments.md +0 -46
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/LICENSE +0 -0
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/scripts/render_readme.py +0 -0
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/scripts/setup-dev.sh +0 -0
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/src/gridfinity_rebase/__init__.py +0 -0
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/src/gridfinity_rebase/__main__.py +0 -0
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/src/gridfinity_rebase/catalog.py +0 -0
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/src/gridfinity_rebase/cli.py +0 -0
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/src/gridfinity_rebase/errors.py +0 -0
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/src/gridfinity_rebase/options.py +0 -0
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/src/gridfinity_rebase/presets.py +0 -0
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/src/gridfinity_rebase/py.typed +0 -0
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/fixtures/README.md +0 -0
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/fixtures/gf_bin_1x1_h6_bin_v01.stl +0 -0
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/fixtures/test-rebased.stl +0 -0
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/fixtures/test.stl +0 -0
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/test_api.py +0 -0
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/test_cli.py +0 -0
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/test_core.py +0 -0
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/test_only_corners.py +0 -0
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/test_options.py +0 -0
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/test_presets.py +0 -0
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/test_side_pocket_height.py +0 -0
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/test_standard_bins.py +0 -0
- {gridfinity_rebase-0.1.2 → gridfinity_rebase-0.2.0}/tests/test_thumbscrew.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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/**", "
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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 =
|
|
48
|
+
base = GeneratedBase(resolve_options(option[1]), segments=SEGMENTS)
|
|
51
49
|
else:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
228
|
+
def _normalize(model: Model) -> NormalizedModel:
|
|
219
229
|
with _geometry_errors("invalid_grid"):
|
|
220
|
-
return
|
|
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,
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
272
|
+
normalized = _normalize(model)
|
|
259
273
|
_notify(on_progress, "detecting_base")
|
|
260
|
-
info = _inspect(model,
|
|
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
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
333
|
-
|
|
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(
|
|
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 =
|
|
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=
|
|
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 =
|
|
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
|
-
|
|
104
|
-
for
|
|
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
|
-
|
|
107
|
-
or
|
|
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
|
-
|
|
248
|
-
|
|
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]
|
|
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
|
-
|
|
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
|