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 ADDED
@@ -0,0 +1,3 @@
1
+ """STL preparation toolkit for resin 3D printing: scale, layout, fill, autopack, info."""
2
+
3
+ __version__ = "0.2.0"
stlbench/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from stlbench.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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,4 @@
1
+ from stlbench.config.loader import AppConfig, load_app_settings, load_config
2
+ from stlbench.config.schema import AppSettings
3
+
4
+ __all__ = ["AppConfig", "AppSettings", "load_app_settings", "load_config"]
@@ -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