stlbench 0.2.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.
- stlbench/__init__.py +3 -0
- stlbench/__main__.py +4 -0
- stlbench/cli.py +275 -0
- stlbench/config/__init__.py +4 -0
- stlbench/config/loader.py +83 -0
- stlbench/config/schema.py +62 -0
- stlbench/core/__init__.py +31 -0
- stlbench/core/fit.py +148 -0
- stlbench/core/orientation.py +159 -0
- stlbench/export/__init__.py +3 -0
- stlbench/export/plate.py +64 -0
- stlbench/hollow/__init__.py +3 -0
- stlbench/hollow/meshlib_note.md +5 -0
- stlbench/hollow/voxel_shell.py +45 -0
- stlbench/packing/__init__.py +14 -0
- stlbench/packing/layout_orientation.py +103 -0
- stlbench/packing/rectpack_plate.py +125 -0
- stlbench/packing/shelf.py +105 -0
- stlbench/pipeline/__init__.py +18 -0
- stlbench/pipeline/common.py +79 -0
- stlbench/pipeline/mesh_io.py +25 -0
- stlbench/pipeline/run_autopack.py +207 -0
- stlbench/pipeline/run_fill.py +206 -0
- stlbench/pipeline/run_info.py +98 -0
- stlbench/pipeline/run_layout.py +132 -0
- stlbench/pipeline/run_scale.py +273 -0
- stlbench/py.typed +0 -0
- stlbench/supports/__init__.py +5 -0
- stlbench/supports/external.py +15 -0
- stlbench-0.2.0.dist-info/METADATA +204 -0
- stlbench-0.2.0.dist-info/RECORD +34 -0
- stlbench-0.2.0.dist-info/WHEEL +4 -0
- stlbench-0.2.0.dist-info/entry_points.txt +3 -0
- stlbench-0.2.0.dist-info/licenses/LICENSE +21 -0
stlbench/__init__.py
ADDED
stlbench/__main__.py
ADDED
stlbench/cli.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Typer CLI: ``stlbench`` / ``python -m stlbench``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from stlbench.config.loader import load_app_settings
|
|
13
|
+
from stlbench.pipeline.run_autopack import AutopackRunArgs, run_autopack
|
|
14
|
+
from stlbench.pipeline.run_fill import FillRunArgs, run_fill
|
|
15
|
+
from stlbench.pipeline.run_info import InfoRunArgs, run_info
|
|
16
|
+
from stlbench.pipeline.run_layout import LayoutRunArgs, run_layout
|
|
17
|
+
from stlbench.pipeline.run_scale import ScaleRunArgs, run_scale
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(
|
|
20
|
+
no_args_is_help=True,
|
|
21
|
+
help="STL preparation for resin 3D printing: scale, layout, fill, autopack, info.",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parse_printer_opt(value: str | None) -> tuple[float, float, float] | None:
|
|
26
|
+
if value is None or not str(value).strip():
|
|
27
|
+
return None
|
|
28
|
+
parts = re.split(r"[\s,xX]+", str(value).strip())
|
|
29
|
+
nums = [float(p) for p in parts if p]
|
|
30
|
+
if len(nums) != 3:
|
|
31
|
+
raise typer.BadParameter("Нужно ровно три числа: Px Py Pz или Px,Py,Pz.")
|
|
32
|
+
return nums[0], nums[1], nums[2]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.command("scale")
|
|
36
|
+
def cmd_scale(
|
|
37
|
+
input_dir: Annotated[
|
|
38
|
+
Path,
|
|
39
|
+
typer.Option("--input", "-i", exists=True, file_okay=False, dir_okay=True),
|
|
40
|
+
],
|
|
41
|
+
output_dir: Annotated[Path, typer.Option("--output", "-o")],
|
|
42
|
+
config: Annotated[
|
|
43
|
+
Path | None,
|
|
44
|
+
typer.Option("--config", "-c", exists=True, dir_okay=False, file_okay=True),
|
|
45
|
+
] = None,
|
|
46
|
+
printer: Annotated[
|
|
47
|
+
str | None,
|
|
48
|
+
typer.Option(
|
|
49
|
+
"--printer",
|
|
50
|
+
"-p",
|
|
51
|
+
help="Три числа: например 153.36,77.76,165",
|
|
52
|
+
),
|
|
53
|
+
] = None,
|
|
54
|
+
margin: Annotated[float | None, typer.Option("--margin")] = None,
|
|
55
|
+
supports_scale: Annotated[float | None, typer.Option("--supports-scale")] = None,
|
|
56
|
+
method: Annotated[str | None, typer.Option("--method")] = None,
|
|
57
|
+
orientation: Annotated[str | None, typer.Option("--orientation")] = None,
|
|
58
|
+
rotation_samples: Annotated[int | None, typer.Option("--rotation-samples")] = None,
|
|
59
|
+
no_upscale: Annotated[bool, typer.Option("--no-upscale")] = False,
|
|
60
|
+
dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
|
|
61
|
+
recursive: Annotated[bool, typer.Option("--recursive")] = False,
|
|
62
|
+
suffix: Annotated[str, typer.Option("--suffix", show_default=False)] = "",
|
|
63
|
+
no_packing_report: Annotated[bool, typer.Option("--no-packing-report")] = False,
|
|
64
|
+
hollow: Annotated[bool | None, typer.Option("--hollow/--no-hollow")] = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
st = load_app_settings(config) if config else None
|
|
67
|
+
pr = _parse_printer_opt(printer)
|
|
68
|
+
raise typer.Exit(
|
|
69
|
+
run_scale(
|
|
70
|
+
ScaleRunArgs(
|
|
71
|
+
input_dir=input_dir,
|
|
72
|
+
output_dir=output_dir,
|
|
73
|
+
config_path=config,
|
|
74
|
+
settings=st,
|
|
75
|
+
printer_xyz=pr,
|
|
76
|
+
margin=margin,
|
|
77
|
+
supports_scale=supports_scale,
|
|
78
|
+
method=method,
|
|
79
|
+
orientation=orientation,
|
|
80
|
+
rotation_samples=rotation_samples,
|
|
81
|
+
no_upscale=no_upscale,
|
|
82
|
+
dry_run=dry_run,
|
|
83
|
+
recursive=recursive,
|
|
84
|
+
suffix=suffix,
|
|
85
|
+
no_packing_report=no_packing_report,
|
|
86
|
+
hollow_override=hollow,
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command("layout")
|
|
93
|
+
def cmd_layout(
|
|
94
|
+
input_dir: Annotated[
|
|
95
|
+
Path,
|
|
96
|
+
typer.Option("--input", "-i", exists=True, file_okay=False, dir_okay=True),
|
|
97
|
+
],
|
|
98
|
+
output_dir: Annotated[Path, typer.Option("--output", "-o")],
|
|
99
|
+
config: Annotated[
|
|
100
|
+
Path | None,
|
|
101
|
+
typer.Option("--config", "-c", exists=True, dir_okay=False, file_okay=True),
|
|
102
|
+
] = None,
|
|
103
|
+
printer: Annotated[
|
|
104
|
+
str | None,
|
|
105
|
+
typer.Option("-p", "--printer", help="Px,Py,Pz"),
|
|
106
|
+
] = None,
|
|
107
|
+
gap_mm: Annotated[float | None, typer.Option("--gap-mm")] = None,
|
|
108
|
+
algorithm: Annotated[str | None, typer.Option("--algorithm")] = None,
|
|
109
|
+
recursive: Annotated[bool, typer.Option("--recursive")] = False,
|
|
110
|
+
dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
|
|
111
|
+
) -> None:
|
|
112
|
+
pr = _parse_printer_opt(printer)
|
|
113
|
+
raise typer.Exit(
|
|
114
|
+
run_layout(
|
|
115
|
+
LayoutRunArgs(
|
|
116
|
+
input_dir=input_dir,
|
|
117
|
+
output_dir=output_dir,
|
|
118
|
+
config_path=config,
|
|
119
|
+
printer_xyz=pr,
|
|
120
|
+
gap_mm=gap_mm,
|
|
121
|
+
algorithm=algorithm,
|
|
122
|
+
recursive=recursive,
|
|
123
|
+
dry_run=dry_run,
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.command("fill")
|
|
130
|
+
def cmd_fill(
|
|
131
|
+
input_file: Annotated[
|
|
132
|
+
Path,
|
|
133
|
+
typer.Option("--input", "-i", exists=True, help="Single STL file or directory with one STL"),
|
|
134
|
+
],
|
|
135
|
+
output_dir: Annotated[Path, typer.Option("--output", "-o")],
|
|
136
|
+
config: Annotated[
|
|
137
|
+
Path | None,
|
|
138
|
+
typer.Option("--config", "-c", exists=True, dir_okay=False, file_okay=True),
|
|
139
|
+
] = None,
|
|
140
|
+
printer: Annotated[
|
|
141
|
+
str | None,
|
|
142
|
+
typer.Option("-p", "--printer", help="Px,Py,Pz"),
|
|
143
|
+
] = None,
|
|
144
|
+
gap_mm: Annotated[float | None, typer.Option("--gap-mm")] = None,
|
|
145
|
+
scale: Annotated[bool, typer.Option("--scale/--no-scale")] = False,
|
|
146
|
+
dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
|
|
147
|
+
) -> None:
|
|
148
|
+
pr = _parse_printer_opt(printer)
|
|
149
|
+
raise typer.Exit(
|
|
150
|
+
run_fill(
|
|
151
|
+
FillRunArgs(
|
|
152
|
+
input_file=input_file,
|
|
153
|
+
output_dir=output_dir,
|
|
154
|
+
config_path=config,
|
|
155
|
+
printer_xyz=pr,
|
|
156
|
+
gap_mm=gap_mm,
|
|
157
|
+
scale=scale,
|
|
158
|
+
dry_run=dry_run,
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@app.command("autopack")
|
|
165
|
+
def cmd_autopack(
|
|
166
|
+
input_dir: Annotated[
|
|
167
|
+
Path,
|
|
168
|
+
typer.Option("--input", "-i", exists=True, file_okay=False, dir_okay=True),
|
|
169
|
+
],
|
|
170
|
+
output_dir: Annotated[Path, typer.Option("--output", "-o")],
|
|
171
|
+
config: Annotated[
|
|
172
|
+
Path | None,
|
|
173
|
+
typer.Option("--config", "-c", exists=True, dir_okay=False, file_okay=True),
|
|
174
|
+
] = None,
|
|
175
|
+
printer: Annotated[
|
|
176
|
+
str | None,
|
|
177
|
+
typer.Option("-p", "--printer", help="Px,Py,Pz"),
|
|
178
|
+
] = None,
|
|
179
|
+
gap_mm: Annotated[float | None, typer.Option("--gap-mm")] = None,
|
|
180
|
+
margin: Annotated[float | None, typer.Option("--margin")] = None,
|
|
181
|
+
supports_scale: Annotated[float | None, typer.Option("--supports-scale")] = None,
|
|
182
|
+
recursive: Annotated[bool, typer.Option("--recursive")] = False,
|
|
183
|
+
dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
|
|
184
|
+
) -> None:
|
|
185
|
+
pr = _parse_printer_opt(printer)
|
|
186
|
+
raise typer.Exit(
|
|
187
|
+
run_autopack(
|
|
188
|
+
AutopackRunArgs(
|
|
189
|
+
input_dir=input_dir,
|
|
190
|
+
output_dir=output_dir,
|
|
191
|
+
config_path=config,
|
|
192
|
+
printer_xyz=pr,
|
|
193
|
+
gap_mm=gap_mm,
|
|
194
|
+
margin=margin,
|
|
195
|
+
supports_scale=supports_scale,
|
|
196
|
+
dry_run=dry_run,
|
|
197
|
+
recursive=recursive,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.command("info")
|
|
204
|
+
def cmd_info(
|
|
205
|
+
input_dir: Annotated[
|
|
206
|
+
Path,
|
|
207
|
+
typer.Option("--input", "-i", exists=True, file_okay=False, dir_okay=True),
|
|
208
|
+
],
|
|
209
|
+
config: Annotated[
|
|
210
|
+
Path | None,
|
|
211
|
+
typer.Option("--config", "-c", exists=True, dir_okay=False, file_okay=True),
|
|
212
|
+
] = None,
|
|
213
|
+
printer: Annotated[
|
|
214
|
+
str | None,
|
|
215
|
+
typer.Option("-p", "--printer", help="Px,Py,Pz"),
|
|
216
|
+
] = None,
|
|
217
|
+
recursive: Annotated[bool, typer.Option("--recursive")] = False,
|
|
218
|
+
) -> None:
|
|
219
|
+
pr = _parse_printer_opt(printer)
|
|
220
|
+
raise typer.Exit(
|
|
221
|
+
run_info(
|
|
222
|
+
InfoRunArgs(
|
|
223
|
+
input_dir=input_dir,
|
|
224
|
+
config_path=config,
|
|
225
|
+
printer_xyz=pr,
|
|
226
|
+
recursive=recursive,
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@app.command("hollow")
|
|
233
|
+
def cmd_hollow_info() -> None:
|
|
234
|
+
typer.echo("Configure [hollow] section in TOML and run: stlbench scale ... --hollow")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@app.command("supports")
|
|
238
|
+
def cmd_supports_info() -> None:
|
|
239
|
+
typer.echo(
|
|
240
|
+
"Supports are not generated by stlbench. Open exported STL in Lychee, Chitubox or another slicer."
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
_KNOWN_COMMANDS = frozenset(
|
|
245
|
+
{"scale", "layout", "fill", "autopack", "info", "hollow", "supports", "-h", "--help"}
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def launch() -> None:
|
|
250
|
+
if len(sys.argv) > 1 and sys.argv[1] not in _KNOWN_COMMANDS:
|
|
251
|
+
sys.argv.insert(1, "scale")
|
|
252
|
+
app()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def main(argv: list[str] | None = None) -> int:
|
|
256
|
+
old = sys.argv
|
|
257
|
+
try:
|
|
258
|
+
if argv is not None:
|
|
259
|
+
sys.argv = [old[0]] + list(argv)
|
|
260
|
+
try:
|
|
261
|
+
launch()
|
|
262
|
+
return 0
|
|
263
|
+
except SystemExit as e:
|
|
264
|
+
code = e.code
|
|
265
|
+
if code is None:
|
|
266
|
+
return 0
|
|
267
|
+
if isinstance(code, int):
|
|
268
|
+
return code
|
|
269
|
+
return 1
|
|
270
|
+
finally:
|
|
271
|
+
sys.argv = old
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
if __name__ == "__main__":
|
|
275
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import cast
|
|
5
|
+
|
|
6
|
+
import tomli
|
|
7
|
+
|
|
8
|
+
from stlbench.config.schema import AppSettings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_app_settings(path: Path) -> AppSettings:
|
|
12
|
+
if not path.is_file():
|
|
13
|
+
raise FileNotFoundError(f"Config not found: {path}")
|
|
14
|
+
with path.open("rb") as f:
|
|
15
|
+
raw = tomli.load(f)
|
|
16
|
+
printer = raw.get("printer") or {}
|
|
17
|
+
if "width_mm" not in printer:
|
|
18
|
+
raise ValueError("config [printer] must define width_mm, depth_mm, height_mm.")
|
|
19
|
+
data = {
|
|
20
|
+
"printer": printer,
|
|
21
|
+
"scaling": raw.get("scaling") or {},
|
|
22
|
+
"orientation": raw.get("orientation") or {},
|
|
23
|
+
"packing": raw.get("packing") or {},
|
|
24
|
+
"hollow": raw.get("hollow") or {},
|
|
25
|
+
"supports": raw.get("supports") or {},
|
|
26
|
+
}
|
|
27
|
+
return cast(AppSettings, AppSettings.model_validate(data))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Backward-compatible flat view for legacy code paths
|
|
31
|
+
class AppConfig:
|
|
32
|
+
"""Плоский доступ к полям (совместимость со старым API)."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, s: AppSettings) -> None:
|
|
35
|
+
self._s = s
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def printer_name(self) -> str:
|
|
39
|
+
return self._s.printer.name
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def width_mm(self) -> float:
|
|
43
|
+
return self._s.printer.width_mm
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def depth_mm(self) -> float:
|
|
47
|
+
return self._s.printer.depth_mm
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def height_mm(self) -> float:
|
|
51
|
+
return self._s.printer.height_mm
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def bed_margin(self) -> float:
|
|
55
|
+
return self._s.scaling.bed_margin
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def supports_scale(self) -> float:
|
|
59
|
+
return self._s.scaling.supports_scale
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def orientation_mode(self) -> str:
|
|
63
|
+
return self._s.orientation.mode
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def orientation_samples(self) -> int:
|
|
67
|
+
return self._s.orientation.samples
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def orientation_seed(self) -> int:
|
|
71
|
+
return self._s.orientation.seed
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def packing_report(self) -> bool:
|
|
75
|
+
return self._s.packing.report
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def settings(self) -> AppSettings:
|
|
79
|
+
return self._s
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_config(path: Path) -> AppConfig:
|
|
83
|
+
return AppConfig(load_app_settings(path))
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field, field_validator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PrinterSection(BaseModel):
|
|
9
|
+
name: str = ""
|
|
10
|
+
width_mm: float = Field(gt=0)
|
|
11
|
+
depth_mm: float = Field(gt=0)
|
|
12
|
+
height_mm: float = Field(gt=0)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ScalingSection(BaseModel):
|
|
16
|
+
bed_margin: float = Field(default=0.0, ge=0.0, lt=1.0)
|
|
17
|
+
supports_scale: float = Field(default=1.0, gt=0.0)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OrientationSection(BaseModel):
|
|
21
|
+
mode: Literal["axis", "free"] = "axis"
|
|
22
|
+
samples: int = Field(default=2048, ge=1)
|
|
23
|
+
seed: int = 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PackingSection(BaseModel):
|
|
27
|
+
report: bool = True
|
|
28
|
+
algorithm: Literal["shelf", "rectpack"] = "rectpack"
|
|
29
|
+
gap_mm: float = Field(default=2.0, ge=0.0)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class HollowSection(BaseModel):
|
|
33
|
+
enabled: bool = False
|
|
34
|
+
backend: Literal["none", "open3d_voxel"] = "none"
|
|
35
|
+
wall_thickness_mm: float = Field(default=2.0, gt=0.0)
|
|
36
|
+
voxel_mm: float = Field(default=0.5, gt=0.0)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SupportsSection(BaseModel):
|
|
40
|
+
"""
|
|
41
|
+
Заглушка для совместимости TOML. Генерации опор в пакете нет — только слайсер.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
enabled: bool = False
|
|
45
|
+
backend: Literal["none", "external"] = "none"
|
|
46
|
+
external_command_template: str = ""
|
|
47
|
+
|
|
48
|
+
@field_validator("external_command_template", mode="before")
|
|
49
|
+
@classmethod
|
|
50
|
+
def _empty_str(cls, v: object) -> str:
|
|
51
|
+
if v is None:
|
|
52
|
+
return ""
|
|
53
|
+
return str(v)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AppSettings(BaseModel):
|
|
57
|
+
printer: PrinterSection
|
|
58
|
+
scaling: ScalingSection = Field(default_factory=ScalingSection)
|
|
59
|
+
orientation: OrientationSection = Field(default_factory=OrientationSection)
|
|
60
|
+
packing: PackingSection = Field(default_factory=PackingSection)
|
|
61
|
+
hollow: HollowSection = Field(default_factory=HollowSection)
|
|
62
|
+
supports: SupportsSection = Field(default_factory=SupportsSection)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Core scaling and orientation math."""
|
|
2
|
+
|
|
3
|
+
from stlbench.core.fit import (
|
|
4
|
+
Method,
|
|
5
|
+
PartScaleReport,
|
|
6
|
+
aabb_edge_lengths,
|
|
7
|
+
compute_global_scale,
|
|
8
|
+
limiting_part_index,
|
|
9
|
+
printer_dims_with_margin,
|
|
10
|
+
s_max_for_part_conservative,
|
|
11
|
+
s_max_for_part_sorted,
|
|
12
|
+
)
|
|
13
|
+
from stlbench.core.orientation import (
|
|
14
|
+
best_aabb_extents_for_conservative_fit,
|
|
15
|
+
best_aabb_extents_for_sorted_fit,
|
|
16
|
+
mesh_vertices_for_orientation,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Method",
|
|
21
|
+
"PartScaleReport",
|
|
22
|
+
"aabb_edge_lengths",
|
|
23
|
+
"compute_global_scale",
|
|
24
|
+
"limiting_part_index",
|
|
25
|
+
"printer_dims_with_margin",
|
|
26
|
+
"s_max_for_part_conservative",
|
|
27
|
+
"s_max_for_part_sorted",
|
|
28
|
+
"best_aabb_extents_for_conservative_fit",
|
|
29
|
+
"best_aabb_extents_for_sorted_fit",
|
|
30
|
+
"mesh_vertices_for_orientation",
|
|
31
|
+
]
|
stlbench/core/fit.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
Method = Literal["sorted", "conservative"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def printer_dims_with_margin(
|
|
12
|
+
px: float, py: float, pz: float, margin: float
|
|
13
|
+
) -> tuple[float, float, float]:
|
|
14
|
+
if margin < 0 or margin >= 1:
|
|
15
|
+
raise ValueError("margin must be in [0, 1).")
|
|
16
|
+
f = 1.0 - margin
|
|
17
|
+
return px * f, py * f, pz * f
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def aabb_edge_lengths(bounds: np.ndarray) -> tuple[float, float, float]:
|
|
21
|
+
"""bounds: (2, 3) min/max corners."""
|
|
22
|
+
if bounds.shape != (2, 3):
|
|
23
|
+
raise ValueError("bounds must have shape (2, 3).")
|
|
24
|
+
d = bounds[1] - bounds[0]
|
|
25
|
+
return float(d[0]), float(d[1]), float(d[2])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _require_positive_dims(dims: tuple[float, float, float], label: str) -> None:
|
|
29
|
+
if any(x <= 0 for x in dims):
|
|
30
|
+
raise ValueError(f"{label}: all AABB edge lengths must be positive, got {dims}.")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def s_max_for_part_sorted(
|
|
34
|
+
p_sorted: tuple[float, float, float], d_sorted: tuple[float, float, float]
|
|
35
|
+
) -> float:
|
|
36
|
+
_require_positive_dims(d_sorted, "Part")
|
|
37
|
+
p1, p2, p3 = p_sorted
|
|
38
|
+
d1, d2, d3 = d_sorted
|
|
39
|
+
return min(p1 / d1, p2 / d2, p3 / d3)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def s_max_for_part_conservative(p_min: float, dx: float, dy: float, dz: float) -> float:
|
|
43
|
+
dmax = max(dx, dy, dz)
|
|
44
|
+
if dmax <= 0:
|
|
45
|
+
raise ValueError(f"Part max dimension must be positive, got {dmax}.")
|
|
46
|
+
return p_min / dmax
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class PartScaleReport:
|
|
51
|
+
name: str
|
|
52
|
+
dx: float
|
|
53
|
+
dy: float
|
|
54
|
+
dz: float
|
|
55
|
+
s_limit: float
|
|
56
|
+
limiting_axis: str | None
|
|
57
|
+
file_dx: float | None = None
|
|
58
|
+
file_dy: float | None = None
|
|
59
|
+
file_dz: float | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def compute_global_scale(
|
|
63
|
+
printer_xyz: tuple[float, float, float],
|
|
64
|
+
parts_dims: list[tuple[float, float, float]],
|
|
65
|
+
part_names: list[str],
|
|
66
|
+
method: Method,
|
|
67
|
+
file_dims: list[tuple[float, float, float]] | None = None,
|
|
68
|
+
) -> tuple[float, list[PartScaleReport]]:
|
|
69
|
+
if len(parts_dims) != len(part_names):
|
|
70
|
+
raise ValueError("parts_dims and part_names length mismatch.")
|
|
71
|
+
if not parts_dims:
|
|
72
|
+
raise ValueError("No parts to scale.")
|
|
73
|
+
if file_dims is not None and len(file_dims) != len(parts_dims):
|
|
74
|
+
raise ValueError("file_dims length must match parts_dims.")
|
|
75
|
+
|
|
76
|
+
px, py, pz = printer_xyz
|
|
77
|
+
if min(px, py, pz) <= 0:
|
|
78
|
+
raise ValueError("Printer dimensions must be positive.")
|
|
79
|
+
|
|
80
|
+
reports: list[PartScaleReport] = []
|
|
81
|
+
limits: list[float] = []
|
|
82
|
+
|
|
83
|
+
if method == "sorted":
|
|
84
|
+
p_sorted = tuple(sorted((px, py, pz)))
|
|
85
|
+
for i, (name, dims) in enumerate(zip(part_names, parts_dims, strict=True)):
|
|
86
|
+
_require_positive_dims(dims, name)
|
|
87
|
+
d_sorted = tuple(sorted(dims))
|
|
88
|
+
s1 = p_sorted[0] / d_sorted[0]
|
|
89
|
+
s2 = p_sorted[1] / d_sorted[1]
|
|
90
|
+
s3 = p_sorted[2] / d_sorted[2]
|
|
91
|
+
s_lim = min(s1, s2, s3)
|
|
92
|
+
limits.append(s_lim)
|
|
93
|
+
if s_lim == s1:
|
|
94
|
+
axis = "shortest_part_vs_shortest_printer"
|
|
95
|
+
elif s_lim == s2:
|
|
96
|
+
axis = "mid_part_vs_mid_printer"
|
|
97
|
+
else:
|
|
98
|
+
axis = "longest_part_vs_longest_printer"
|
|
99
|
+
fx = fy = fz = None
|
|
100
|
+
if file_dims is not None:
|
|
101
|
+
fx, fy, fz = file_dims[i]
|
|
102
|
+
reports.append(
|
|
103
|
+
PartScaleReport(
|
|
104
|
+
name=name,
|
|
105
|
+
dx=dims[0],
|
|
106
|
+
dy=dims[1],
|
|
107
|
+
dz=dims[2],
|
|
108
|
+
s_limit=s_lim,
|
|
109
|
+
limiting_axis=axis,
|
|
110
|
+
file_dx=fx,
|
|
111
|
+
file_dy=fy,
|
|
112
|
+
file_dz=fz,
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
elif method == "conservative":
|
|
116
|
+
p_min = min(px, py, pz)
|
|
117
|
+
for i, (name, dims) in enumerate(zip(part_names, parts_dims, strict=True)):
|
|
118
|
+
s_lim = s_max_for_part_conservative(p_min, *dims)
|
|
119
|
+
limits.append(s_lim)
|
|
120
|
+
fx = fy = fz = None
|
|
121
|
+
if file_dims is not None:
|
|
122
|
+
fx, fy, fz = file_dims[i]
|
|
123
|
+
reports.append(
|
|
124
|
+
PartScaleReport(
|
|
125
|
+
name=name,
|
|
126
|
+
dx=dims[0],
|
|
127
|
+
dy=dims[1],
|
|
128
|
+
dz=dims[2],
|
|
129
|
+
s_limit=s_lim,
|
|
130
|
+
limiting_axis="max_extent_vs_min_printer",
|
|
131
|
+
file_dx=fx,
|
|
132
|
+
file_dy=fy,
|
|
133
|
+
file_dz=fz,
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
else:
|
|
137
|
+
raise ValueError(f"Unknown method: {method}")
|
|
138
|
+
|
|
139
|
+
s_max = min(limits)
|
|
140
|
+
return s_max, reports
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def limiting_part_index(reports: list[PartScaleReport], s_max: float) -> int:
|
|
144
|
+
eps = 1e-12
|
|
145
|
+
for i, r in enumerate(reports):
|
|
146
|
+
if r.s_limit <= s_max + eps:
|
|
147
|
+
return i
|
|
148
|
+
return 0
|