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 ADDED
@@ -0,0 +1,3 @@
1
+ """weldbox — generate vendor-ready tube laser cut lists for welded box frames."""
2
+
3
+ __version__ = "0.1.0"
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