weldbox 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- weldbox/__init__.py +3 -0
- weldbox/blocking.py +188 -0
- weldbox/catalog.py +153 -0
- weldbox/cli.py +98 -0
- weldbox/consolidate.py +116 -0
- weldbox/dedupe.py +73 -0
- weldbox/features.py +281 -0
- weldbox/frame.py +352 -0
- weldbox/generate.py +180 -0
- weldbox/geometry/__init__.py +0 -0
- weldbox/geometry/assembly.py +177 -0
- weldbox/geometry/cutters.py +106 -0
- weldbox/geometry/member.py +49 -0
- weldbox/geometry/profile.py +34 -0
- weldbox/manifest.py +93 -0
- weldbox/panels/__init__.py +0 -0
- weldbox/panels/dxf.py +47 -0
- weldbox/panels/layout.py +178 -0
- weldbox/shipping.py +89 -0
- weldbox/spec.py +174 -0
- weldbox/units.py +78 -0
- weldbox/vendors/__init__.py +3 -0
- weldbox/vendors/base.py +99 -0
- weldbox/vendors/data/fabtech.yaml +2 -0
- weldbox/vendors/data/oshcut.yaml +2 -0
- weldbox/vendors/data/rfmg.yaml +70 -0
- weldbox/wizard.py +178 -0
- weldbox-0.1.0.dist-info/METADATA +13 -0
- weldbox-0.1.0.dist-info/RECORD +32 -0
- weldbox-0.1.0.dist-info/WHEEL +4 -0
- weldbox-0.1.0.dist-info/entry_points.txt +2 -0
- weldbox-0.1.0.dist-info/licenses/LICENSE +21 -0
weldbox/__init__.py
ADDED
weldbox/blocking.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Expand blocking primitives (level / supports / spanner) into frame members.
|
|
2
|
+
|
|
3
|
+
See frame.py module docstring for the placement conventions.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .frame import FrameBuilder, Layer, Member
|
|
9
|
+
from .spec import BlockingItem, LevelSpec, SpannerSpec, SupportsSpec
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def expand_blocking(b: FrameBuilder, items: list[BlockingItem]) -> None:
|
|
13
|
+
spanner_counts = {"top": 0, "bottom": 0}
|
|
14
|
+
for item in items:
|
|
15
|
+
if isinstance(item, LevelSpec):
|
|
16
|
+
_expand_level(b, item)
|
|
17
|
+
elif isinstance(item, SupportsSpec):
|
|
18
|
+
_expand_supports(b, item)
|
|
19
|
+
elif isinstance(item, SpannerSpec):
|
|
20
|
+
_expand_spanner(b, item, spanner_counts)
|
|
21
|
+
else: # pragma: no cover
|
|
22
|
+
raise TypeError(f"unknown blocking item {item!r}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _level_z_center(b: FrameBuilder, item: LevelSpec) -> float:
|
|
26
|
+
half = b.s / 2
|
|
27
|
+
if item.height_ref == "top_face":
|
|
28
|
+
return item.height - half
|
|
29
|
+
if item.height_ref == "bottom_face":
|
|
30
|
+
return item.height + half
|
|
31
|
+
return item.height # centerline
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _validate_level_height(b: FrameBuilder, item: LevelSpec, name: str, z_center: float) -> None:
|
|
35
|
+
"""A level's rails must clear both the base and top layers. Report the
|
|
36
|
+
violation in the user's own terms (their height + height_ref) with the
|
|
37
|
+
valid range for this box."""
|
|
38
|
+
lo_center, hi_center = 1.5 * b.s, b.H - 1.5 * b.s
|
|
39
|
+
if lo_center <= z_center <= hi_center:
|
|
40
|
+
return
|
|
41
|
+
ref_offset = {"top_face": b.s / 2, "centerline": 0.0, "bottom_face": -b.s / 2}[
|
|
42
|
+
item.height_ref
|
|
43
|
+
]
|
|
44
|
+
lo, hi = lo_center + ref_offset, hi_center + ref_offset
|
|
45
|
+
where = (
|
|
46
|
+
f"is above the box (exterior height {b.H:g}mm)"
|
|
47
|
+
if item.height > b.H
|
|
48
|
+
else "collides with the base or top frame"
|
|
49
|
+
)
|
|
50
|
+
hint = (
|
|
51
|
+
" The top frame already provides a surface at the exterior height."
|
|
52
|
+
if z_center > hi_center
|
|
53
|
+
else ""
|
|
54
|
+
)
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"level {name!r}: height {item.height:g}mm ({item.height_ref}) {where}. "
|
|
57
|
+
f"With {b.s:g}mm tube and a {b.H:g}mm tall box, a level's height "
|
|
58
|
+
f"({item.height_ref}) must be between {lo:g}mm and {hi:g}mm.{hint}"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _expand_level(b: FrameBuilder, item: LevelSpec) -> None:
|
|
63
|
+
name = item.name or f"level@{item.height:g}"
|
|
64
|
+
z_center = _level_z_center(b, item)
|
|
65
|
+
_validate_level_height(b, item, name, z_center)
|
|
66
|
+
layer = b.build_layer(name, z_center, "tee")
|
|
67
|
+
|
|
68
|
+
if item.cross_members is None:
|
|
69
|
+
return
|
|
70
|
+
n = item.cross_members.count
|
|
71
|
+
s, wg = b.s, b.wg
|
|
72
|
+
if item.cross_members.axis == "depth":
|
|
73
|
+
# members along Y, butted between the layer's front and back rails,
|
|
74
|
+
# evenly spaced across the exterior width
|
|
75
|
+
for k in range(1, n + 1):
|
|
76
|
+
x = b.W * k / (n + 1)
|
|
77
|
+
m = b.add_member(
|
|
78
|
+
Member(
|
|
79
|
+
id=f"{name}-cross-{k}",
|
|
80
|
+
role="cross",
|
|
81
|
+
profile=b.profile,
|
|
82
|
+
axis="y",
|
|
83
|
+
origin=(x, s + wg, z_center),
|
|
84
|
+
length=b.D - 2 * s - 2 * wg,
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
b._butt("tee", m.id, 0, layer.rails["front"], "+y", x)
|
|
88
|
+
b._butt("tee", m.id, 1, layer.rails["back"], "-y", x)
|
|
89
|
+
else: # axis == "width": members along X between left and right rails
|
|
90
|
+
for k in range(1, n + 1):
|
|
91
|
+
y = b.D * k / (n + 1)
|
|
92
|
+
m = b.add_member(
|
|
93
|
+
Member(
|
|
94
|
+
id=f"{name}-cross-{k}",
|
|
95
|
+
role="cross",
|
|
96
|
+
profile=b.profile,
|
|
97
|
+
axis="x",
|
|
98
|
+
origin=(s + wg, y, z_center),
|
|
99
|
+
length=b.W - 2 * s - 2 * wg,
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
b._butt("tee", m.id, 0, layer.rails["left"], "+x", y)
|
|
103
|
+
b._butt("tee", m.id, 1, layer.rails["right"], "-x", y)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _resolve_layer(b: FrameBuilder, ref: str) -> Layer:
|
|
107
|
+
if ref in b.layers:
|
|
108
|
+
return b.layers[ref]
|
|
109
|
+
raise LookupError(
|
|
110
|
+
f"unknown layer {ref!r} in supports.between; "
|
|
111
|
+
f"available: {', '.join(sorted(b.layers))}"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _expand_supports(b: FrameBuilder, item: SupportsSpec) -> None:
|
|
116
|
+
lo, hi = sorted((_resolve_layer(b, r) for r in item.between), key=lambda l: l.z_center)
|
|
117
|
+
s, wg = b.s, b.wg
|
|
118
|
+
z0 = lo.z_center + s / 2 + wg # top face of lower rails
|
|
119
|
+
z1 = hi.z_center - s / 2 - wg # bottom face of upper rails
|
|
120
|
+
length = z1 - z0
|
|
121
|
+
if length <= 0:
|
|
122
|
+
raise ValueError(f"supports between {lo.name!r} and {hi.name!r} have no span")
|
|
123
|
+
|
|
124
|
+
# one support at the midpoint of each of the 4 rail pairs
|
|
125
|
+
half = s / 2
|
|
126
|
+
positions = {
|
|
127
|
+
"front": (b.W / 2, half),
|
|
128
|
+
"back": (b.W / 2, b.D - half),
|
|
129
|
+
"left": (half, b.D / 2),
|
|
130
|
+
"right": (b.W - half, b.D / 2),
|
|
131
|
+
}
|
|
132
|
+
for key, (x, y) in positions.items():
|
|
133
|
+
m = b.add_member(
|
|
134
|
+
Member(
|
|
135
|
+
id=f"support-{lo.name}-{hi.name}-{key}",
|
|
136
|
+
role="support",
|
|
137
|
+
profile=b.profile,
|
|
138
|
+
axis="z",
|
|
139
|
+
origin=(x, y, z0),
|
|
140
|
+
length=length,
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
mid = b.W / 2 if key in ("front", "back") else b.D / 2
|
|
144
|
+
b._butt("tee", m.id, 0, lo.rails[key], "+z", mid)
|
|
145
|
+
b._butt("tee", m.id, 1, hi.rails[key], "-z", mid)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _expand_spanner(b: FrameBuilder, item: SpannerSpec, counts: dict[str, int]) -> None:
|
|
149
|
+
s, wg = b.s, b.wg
|
|
150
|
+
fractions = (
|
|
151
|
+
[item.position]
|
|
152
|
+
if item.count == 1
|
|
153
|
+
else [k / (item.count + 1) for k in range(1, item.count + 1)]
|
|
154
|
+
)
|
|
155
|
+
for face in item.face:
|
|
156
|
+
layer = _resolve_layer(b, {"top": "top", "bottom": "base"}[face])
|
|
157
|
+
for fraction in fractions:
|
|
158
|
+
counts[face] += 1
|
|
159
|
+
name = f"{face}-spanner-{counts[face]}"
|
|
160
|
+
if item.axis == "width":
|
|
161
|
+
# along X at a fraction of the depth, between left/right rails
|
|
162
|
+
y = b.D * fraction
|
|
163
|
+
m = b.add_member(
|
|
164
|
+
Member(
|
|
165
|
+
id=name,
|
|
166
|
+
role="spanner",
|
|
167
|
+
profile=b.profile,
|
|
168
|
+
axis="x",
|
|
169
|
+
origin=(s + wg, y, layer.z_center),
|
|
170
|
+
length=b.W - 2 * s - 2 * wg,
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
b._butt("tee", m.id, 0, layer.rails["left"], "+x", y)
|
|
174
|
+
b._butt("tee", m.id, 1, layer.rails["right"], "-x", y)
|
|
175
|
+
else: # axis == "depth": along Y between front/back rails
|
|
176
|
+
x = b.W * fraction
|
|
177
|
+
m = b.add_member(
|
|
178
|
+
Member(
|
|
179
|
+
id=name,
|
|
180
|
+
role="spanner",
|
|
181
|
+
profile=b.profile,
|
|
182
|
+
axis="y",
|
|
183
|
+
origin=(x, s + wg, layer.z_center),
|
|
184
|
+
length=b.D - 2 * s - 2 * wg,
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
b._butt("tee", m.id, 0, layer.rails["front"], "+y", x)
|
|
188
|
+
b._butt("tee", m.id, 1, layer.rails["back"], "-y", x)
|
weldbox/catalog.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Vendor tube material catalogs.
|
|
2
|
+
|
|
3
|
+
Profiles are loaded from per-vendor YAML files (see vendors/data/*.yaml),
|
|
4
|
+
hand-encoded from each vendor's published material list. Dimensions in the
|
|
5
|
+
YAML are inches (as published); they are converted to mm on load.
|
|
6
|
+
|
|
7
|
+
Corner radius: some vendor tables (e.g. RFMG stainless and aluminum) omit
|
|
8
|
+
the outside corner radius. Where missing on a square/rect profile we fall
|
|
9
|
+
back to 2 x wall, which matches every steel row RFMG does publish.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
|
|
19
|
+
from .units import MM_PER_INCH
|
|
20
|
+
|
|
21
|
+
MATCH_TOL_MM = 0.02
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class TubeProfile:
|
|
26
|
+
id: str
|
|
27
|
+
shape: str # "square" | "rect" | "round" | "pipe"
|
|
28
|
+
outer_w_mm: float # width (or OD for round)
|
|
29
|
+
outer_h_mm: float # height (== width for square/round)
|
|
30
|
+
wall_mm: float
|
|
31
|
+
corner_r_mm: float | None # outside corner radius; None for round
|
|
32
|
+
material_family: str # e.g. "A500", "304", "6061 T6"
|
|
33
|
+
display_name: str
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def corner_r_resolved_mm(self) -> float:
|
|
37
|
+
"""Outside corner radius, falling back to 2 x wall when unpublished."""
|
|
38
|
+
if self.shape == "round":
|
|
39
|
+
return 0.0
|
|
40
|
+
if self.corner_r_mm is not None:
|
|
41
|
+
return self.corner_r_mm
|
|
42
|
+
return 2.0 * self.wall_mm
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def section_area_mm2(self) -> float:
|
|
46
|
+
"""Analytic hollow-section area (used for weight estimates)."""
|
|
47
|
+
from math import pi
|
|
48
|
+
|
|
49
|
+
w, h, t = self.outer_w_mm, self.outer_h_mm, self.wall_mm
|
|
50
|
+
if self.shape == "round":
|
|
51
|
+
return pi / 4 * (w**2 - (w - 2 * t) ** 2)
|
|
52
|
+
r_out = self.corner_r_resolved_mm if self.corner_r_resolved_mm > 0.01 else 0.0
|
|
53
|
+
r_in = max(r_out - t, 0.0)
|
|
54
|
+
outer = w * h - (4 - pi) * r_out**2
|
|
55
|
+
inner = (w - 2 * t) * (h - 2 * t) - (4 - pi) * r_in**2
|
|
56
|
+
return outer - inner
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def density_g_cm3(self) -> float:
|
|
60
|
+
return density_for(self.material_family)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def density_for(material_family: str) -> float:
|
|
64
|
+
fam = material_family.lower()
|
|
65
|
+
if "6061" in fam or "6063" in fam:
|
|
66
|
+
return 2.70 # aluminum
|
|
67
|
+
if "304" in fam or "stainless" in fam:
|
|
68
|
+
return 8.00 # stainless
|
|
69
|
+
return 7.85 # carbon/alloy steel (A500, 4130, DOM)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class SheetMaterial:
|
|
74
|
+
alloy: str
|
|
75
|
+
thickness_mm: float
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class Catalog:
|
|
80
|
+
vendor: str
|
|
81
|
+
profiles: list[TubeProfile] = field(default_factory=list)
|
|
82
|
+
|
|
83
|
+
def find(
|
|
84
|
+
self,
|
|
85
|
+
shape: str,
|
|
86
|
+
outer_w_mm: float,
|
|
87
|
+
outer_h_mm: float,
|
|
88
|
+
wall_mm: float,
|
|
89
|
+
material_family: str | None = None,
|
|
90
|
+
) -> TubeProfile:
|
|
91
|
+
shape = _normalize_shape(shape)
|
|
92
|
+
matches = [
|
|
93
|
+
p
|
|
94
|
+
for p in self.profiles
|
|
95
|
+
if _normalize_shape(p.shape) == shape
|
|
96
|
+
and _close(p.wall_mm, wall_mm)
|
|
97
|
+
and (
|
|
98
|
+
(_close(p.outer_w_mm, outer_w_mm) and _close(p.outer_h_mm, outer_h_mm))
|
|
99
|
+
or (_close(p.outer_w_mm, outer_h_mm) and _close(p.outer_h_mm, outer_w_mm))
|
|
100
|
+
)
|
|
101
|
+
]
|
|
102
|
+
if material_family:
|
|
103
|
+
fam = material_family.lower()
|
|
104
|
+
matches = [p for p in matches if fam in p.material_family.lower()]
|
|
105
|
+
if not matches:
|
|
106
|
+
raise LookupError(
|
|
107
|
+
f"no {shape} profile {outer_w_mm:g}x{outer_h_mm:g}mm wall {wall_mm:g}mm"
|
|
108
|
+
f"{' (' + material_family + ')' if material_family else ''}"
|
|
109
|
+
f" in {self.vendor} catalog"
|
|
110
|
+
)
|
|
111
|
+
if len(matches) > 1:
|
|
112
|
+
names = ", ".join(p.display_name for p in matches)
|
|
113
|
+
raise LookupError(
|
|
114
|
+
f"ambiguous profile match in {self.vendor} catalog ({names}); "
|
|
115
|
+
"specify material family to disambiguate"
|
|
116
|
+
)
|
|
117
|
+
return matches[0]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _close(a: float, b: float, tol: float = MATCH_TOL_MM) -> bool:
|
|
121
|
+
return abs(a - b) <= tol
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _normalize_shape(shape: str) -> str:
|
|
125
|
+
s = shape.lower()
|
|
126
|
+
return {"rectangular": "rect", "rectangle": "rect"}.get(s, s)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load_catalog(path: Path, vendor: str) -> Catalog:
|
|
130
|
+
"""Load a vendor catalog YAML (dimensions in inches) into mm profiles."""
|
|
131
|
+
data = yaml.safe_load(path.read_text()) or {}
|
|
132
|
+
profiles = []
|
|
133
|
+
for row in data.get("profiles", []):
|
|
134
|
+
shape = _normalize_shape(row["shape"])
|
|
135
|
+
outer = row["outer_in"]
|
|
136
|
+
if isinstance(outer, (int, float)):
|
|
137
|
+
outer_w = outer_h = float(outer)
|
|
138
|
+
else:
|
|
139
|
+
outer_w, outer_h = (float(v) for v in outer)
|
|
140
|
+
corner = row.get("corner_r_in")
|
|
141
|
+
profiles.append(
|
|
142
|
+
TubeProfile(
|
|
143
|
+
id=row["id"],
|
|
144
|
+
shape=shape,
|
|
145
|
+
outer_w_mm=outer_w * MM_PER_INCH,
|
|
146
|
+
outer_h_mm=outer_h * MM_PER_INCH,
|
|
147
|
+
wall_mm=float(row["wall_in"]) * MM_PER_INCH,
|
|
148
|
+
corner_r_mm=None if corner is None else float(corner) * MM_PER_INCH,
|
|
149
|
+
material_family=str(row["family"]),
|
|
150
|
+
display_name=row["name"],
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
return Catalog(vendor=vendor, profiles=profiles)
|
weldbox/cli.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""weldbox CLI: generate / wizard / catalog."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from .units import inches
|
|
12
|
+
from .vendors import VENDORS, get_vendor
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="Generate vendor-ready tube laser cut lists for welded boxes.")
|
|
15
|
+
catalog_app = typer.Typer(help="Browse vendor material catalogs.")
|
|
16
|
+
app.add_typer(catalog_app, name="catalog")
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@catalog_app.command("list")
|
|
22
|
+
def catalog_list(
|
|
23
|
+
vendor: str = typer.Option("rfmg", help=f"Vendor: {', '.join(sorted(VENDORS))}"),
|
|
24
|
+
shape: str = typer.Option(None, help="Filter by shape: square, rect, round"),
|
|
25
|
+
) -> None:
|
|
26
|
+
"""List tube profiles available from a vendor."""
|
|
27
|
+
v = get_vendor(vendor)
|
|
28
|
+
cat = v.catalog()
|
|
29
|
+
profiles = cat.profiles
|
|
30
|
+
if shape:
|
|
31
|
+
profiles = [p for p in profiles if p.shape == shape.lower().replace("rectangular", "rect")]
|
|
32
|
+
if not profiles:
|
|
33
|
+
console.print(
|
|
34
|
+
f"[yellow]No profiles for vendor '{v.display_name}'"
|
|
35
|
+
f"{f' with shape {shape}' if shape else ''}."
|
|
36
|
+
+ (" Catalog not yet encoded." if not cat.profiles else "")
|
|
37
|
+
)
|
|
38
|
+
raise typer.Exit(1)
|
|
39
|
+
|
|
40
|
+
table = Table(title=f"{v.display_name} tube profiles ({len(profiles)})")
|
|
41
|
+
table.add_column("Profile")
|
|
42
|
+
table.add_column("Shape")
|
|
43
|
+
table.add_column("Outer (in)", justify="right")
|
|
44
|
+
table.add_column("Wall (in)", justify="right")
|
|
45
|
+
table.add_column("Corner R (in)", justify="right")
|
|
46
|
+
table.add_column("Family")
|
|
47
|
+
for p in profiles:
|
|
48
|
+
outer = (
|
|
49
|
+
f"{inches(p.outer_w_mm):g}"
|
|
50
|
+
if p.outer_w_mm == p.outer_h_mm
|
|
51
|
+
else f"{inches(p.outer_w_mm):g} x {inches(p.outer_h_mm):g}"
|
|
52
|
+
)
|
|
53
|
+
corner = "—" if p.shape == "round" else (
|
|
54
|
+
f"{inches(p.corner_r_mm):g}" if p.corner_r_mm is not None
|
|
55
|
+
else f"{inches(p.corner_r_resolved_mm):g}*"
|
|
56
|
+
)
|
|
57
|
+
table.add_row(p.display_name, p.shape, outer, f"{inches(p.wall_mm):g}", corner, p.material_family)
|
|
58
|
+
console.print(table)
|
|
59
|
+
console.print("[dim]* corner radius not published; 2 x wall assumed[/dim]")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command()
|
|
63
|
+
def generate(
|
|
64
|
+
spec_path: Path = typer.Argument(..., help="Path to a box spec YAML file"),
|
|
65
|
+
out: Path = typer.Option(Path("out"), "-o", "--out", help="Output directory"),
|
|
66
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print cut list only, no CAD"),
|
|
67
|
+
skip_assembly: bool = typer.Option(False, help="Skip the combined assembly STEP"),
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Generate the cut list (STEP files, panel DXFs, manifest) from a spec."""
|
|
70
|
+
from pydantic import ValidationError
|
|
71
|
+
|
|
72
|
+
from .generate import run_generate
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
run_generate(spec_path, out, dry_run=dry_run, skip_assembly=skip_assembly, console=console)
|
|
76
|
+
except ValidationError as exc:
|
|
77
|
+
console.print(f"[red]error:[/red] invalid spec {spec_path}:")
|
|
78
|
+
for err in exc.errors():
|
|
79
|
+
loc = ".".join(str(p) for p in err["loc"])
|
|
80
|
+
console.print(f" [red]•[/red] {loc}: {err['msg']}")
|
|
81
|
+
raise typer.Exit(1) from None
|
|
82
|
+
except (ValueError, LookupError, NotImplementedError) as exc:
|
|
83
|
+
console.print(f"[red]error:[/red] {exc}")
|
|
84
|
+
raise typer.Exit(1) from None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@app.command()
|
|
88
|
+
def wizard(
|
|
89
|
+
spec_path: Path = typer.Argument(None, help="Existing spec YAML to edit"),
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Interactively author a box spec YAML."""
|
|
92
|
+
from .wizard import run_wizard
|
|
93
|
+
|
|
94
|
+
run_wizard(spec_path, console=console)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
app()
|
weldbox/consolidate.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Part-count consolidation: add sacrificial slots/holes so near-identical
|
|
2
|
+
members become the SAME stock part.
|
|
3
|
+
|
|
4
|
+
Members that share a profile and cut length often differ only in which
|
|
5
|
+
faces carry slots or rivet holes (e.g. the two front posts lack the
|
|
6
|
+
front-face rivet holes the back posts have). For each (profile, length)
|
|
7
|
+
group this pass aligns every member to a reference via the square-tube
|
|
8
|
+
symmetry group, unions the feature sets, and writes the union back to each
|
|
9
|
+
member — so all of them collapse to one part in dedupe. The extra features
|
|
10
|
+
are cosmetic through-wall cuts (unused slots or holes); they never remove a
|
|
11
|
+
tab and never overlap an existing feature.
|
|
12
|
+
|
|
13
|
+
Alignment is chosen greedily to minimize the union size. A member whose
|
|
14
|
+
best alignment would still create overlapping features is left out of the
|
|
15
|
+
group and keeps its own part.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from .features import (
|
|
21
|
+
Feature,
|
|
22
|
+
RivetHole,
|
|
23
|
+
SlotFeature,
|
|
24
|
+
TabFeature,
|
|
25
|
+
feature_key,
|
|
26
|
+
inverse_transform,
|
|
27
|
+
transform_feature,
|
|
28
|
+
)
|
|
29
|
+
from .frame import FrameGraph, Member
|
|
30
|
+
|
|
31
|
+
# minimum web of material left between neighboring features
|
|
32
|
+
_CLEARANCE = 0.5
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def consolidate_parts(frame: FrameGraph) -> dict[str, int]:
|
|
36
|
+
"""Union features across same-profile/same-length members. Returns
|
|
37
|
+
{member_id: number_of_added_features} for reporting."""
|
|
38
|
+
from .dedupe import ALL_TRANSFORMS
|
|
39
|
+
|
|
40
|
+
groups: dict[tuple, list[Member]] = {}
|
|
41
|
+
for m in frame.members:
|
|
42
|
+
groups.setdefault((m.profile.id, round(m.length, 2)), []).append(m)
|
|
43
|
+
|
|
44
|
+
added: dict[str, int] = {}
|
|
45
|
+
for members in groups.values():
|
|
46
|
+
if len(members) < 2:
|
|
47
|
+
continue
|
|
48
|
+
# richest feature set first: it anchors the reference frame
|
|
49
|
+
members = sorted(members, key=lambda m: len(m.features), reverse=True)
|
|
50
|
+
ref = members[0]
|
|
51
|
+
part_feats: dict[tuple, Feature] = {
|
|
52
|
+
feature_key(f): f for f in ref.features
|
|
53
|
+
}
|
|
54
|
+
placements: list[tuple[Member, int, bool]] = [(ref, 0, False)]
|
|
55
|
+
|
|
56
|
+
for m in members[1:]:
|
|
57
|
+
best = None # (union_size, q, flipped, mapped_feats)
|
|
58
|
+
for q, flipped in ALL_TRANSFORMS:
|
|
59
|
+
mapped = {
|
|
60
|
+
feature_key(tf): tf
|
|
61
|
+
for tf in (
|
|
62
|
+
transform_feature(f, m.length, q, flipped) for f in m.features
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
union = {**part_feats, **mapped}
|
|
66
|
+
if _has_conflict(union.values()):
|
|
67
|
+
continue
|
|
68
|
+
if best is None or len(union) < best[0]:
|
|
69
|
+
best = (len(union), q, flipped, union)
|
|
70
|
+
if best is None:
|
|
71
|
+
continue # keeps its own part
|
|
72
|
+
_, q, flipped, union = best
|
|
73
|
+
part_feats = union
|
|
74
|
+
placements.append((m, q, flipped))
|
|
75
|
+
|
|
76
|
+
for m, q, flipped in placements:
|
|
77
|
+
iq, iflip = inverse_transform(q, flipped)
|
|
78
|
+
new_features = [
|
|
79
|
+
transform_feature(f, m.length, iq, iflip) for f in part_feats.values()
|
|
80
|
+
]
|
|
81
|
+
n_added = len(new_features) - len(m.features)
|
|
82
|
+
if n_added:
|
|
83
|
+
added[m.id] = n_added
|
|
84
|
+
m.features = new_features
|
|
85
|
+
return added
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _footprint(f: Feature) -> tuple[str, float, float, float, float] | None:
|
|
89
|
+
"""(face, z_min, z_max, lat_min, lat_max) of a cut feature, or None for
|
|
90
|
+
tabs (tabs live on end planes; identical tabs merge by key)."""
|
|
91
|
+
if isinstance(f, SlotFeature):
|
|
92
|
+
dz = f.width / 2 + f.dogbone_r
|
|
93
|
+
dl = f.length / 2 + f.dogbone_r
|
|
94
|
+
return (f.face, f.z - dz, f.z + dz, f.lateral - dl, f.lateral + dl)
|
|
95
|
+
if isinstance(f, RivetHole):
|
|
96
|
+
r = f.dia / 2
|
|
97
|
+
return (f.face, f.z - r, f.z + r, f.lateral - r, f.lateral + r)
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _has_conflict(features) -> bool:
|
|
102
|
+
"""True if any two distinct cut features on the same face overlap (with
|
|
103
|
+
a small clearance web), or a cut crosses a tab's protruding wall."""
|
|
104
|
+
boxes = [fp for f in features if (fp := _footprint(f)) is not None]
|
|
105
|
+
for i, a in enumerate(boxes):
|
|
106
|
+
for b in boxes[i + 1:]:
|
|
107
|
+
if a[0] != b[0]:
|
|
108
|
+
continue
|
|
109
|
+
if (
|
|
110
|
+
a[1] < b[2] + _CLEARANCE
|
|
111
|
+
and b[1] < a[2] + _CLEARANCE
|
|
112
|
+
and a[3] < b[4] + _CLEARANCE
|
|
113
|
+
and b[3] < a[4] + _CLEARANCE
|
|
114
|
+
):
|
|
115
|
+
return True
|
|
116
|
+
return False
|
weldbox/dedupe.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Group physically identical members into unique parts.
|
|
2
|
+
|
|
3
|
+
Two members are the same stock part when one can be mapped onto the other
|
|
4
|
+
by a symmetry of the square tube: rotation about the member axis in 90
|
|
5
|
+
degree steps (4) x end-for-end flip (2) = 8 transforms. The signature is
|
|
6
|
+
the minimum over all 8 transforms of the transformed feature set — so a
|
|
7
|
+
part and its end-swapped or rotated twin collapse together.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
from .features import feature_key, transform_feature
|
|
15
|
+
from .frame import FrameGraph, Member
|
|
16
|
+
|
|
17
|
+
_ROUND = 2 # mm decimals in signatures
|
|
18
|
+
|
|
19
|
+
ALL_TRANSFORMS: list[tuple[int, bool]] = [
|
|
20
|
+
(q, flipped) for flipped in (False, True) for q in (0, 1, 2, 3)
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def transformed_keys(member: Member, quarter_turns: int, flipped: bool) -> tuple:
|
|
25
|
+
return tuple(
|
|
26
|
+
sorted(
|
|
27
|
+
feature_key(transform_feature(f, member.length, quarter_turns, flipped))
|
|
28
|
+
for f in member.features
|
|
29
|
+
)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def part_signature(member: Member) -> tuple:
|
|
34
|
+
variants = [transformed_keys(member, q, flipped) for q, flipped in ALL_TRANSFORMS]
|
|
35
|
+
return (member.profile.id, round(member.length, _ROUND), min(variants))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class UniquePart:
|
|
40
|
+
signature: tuple
|
|
41
|
+
exemplar: Member
|
|
42
|
+
member_ids: list[str] = field(default_factory=list)
|
|
43
|
+
name: str = ""
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def qty(self) -> int:
|
|
47
|
+
return len(self.member_ids)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def group_parts(frame: FrameGraph) -> list[UniquePart]:
|
|
51
|
+
groups: dict[tuple, UniquePart] = {}
|
|
52
|
+
for m in frame.members:
|
|
53
|
+
sig = part_signature(m)
|
|
54
|
+
part = groups.get(sig)
|
|
55
|
+
if part is None:
|
|
56
|
+
part = groups[sig] = UniquePart(signature=sig, exemplar=m)
|
|
57
|
+
part.member_ids.append(m.id)
|
|
58
|
+
|
|
59
|
+
parts = sorted(groups.values(), key=lambda p: (-p.exemplar.length, p.exemplar.id))
|
|
60
|
+
# name each part by its member role (or "member-<length>" when a part is
|
|
61
|
+
# used across several roles); number repeats (rail vs rail-2)
|
|
62
|
+
base_names: list[str] = []
|
|
63
|
+
for part in parts:
|
|
64
|
+
roles = {frame.member(mid).role for mid in part.member_ids}
|
|
65
|
+
if len(roles) == 1:
|
|
66
|
+
base_names.append(next(iter(roles)))
|
|
67
|
+
else:
|
|
68
|
+
base_names.append(f"member-{part.exemplar.length:.0f}mm")
|
|
69
|
+
seen: dict[str, int] = {}
|
|
70
|
+
for part, base in zip(parts, base_names):
|
|
71
|
+
seen[base] = seen.get(base, 0) + 1
|
|
72
|
+
part.name = base if base_names.count(base) == 1 else f"{base}-{seen[base]}"
|
|
73
|
+
return parts
|