File size: 7,037 Bytes
52a2a94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
"""
Parametric vibration test fixture CAD engine (CadQuery).

Design philosophy: the LLM does not emit raw CAD code. Instead it emits a
STRUCTURED FIXTURE SPEC (a validated dict / JSON). This engine deterministically
turns that spec into real geometry and exports STEP, STL, and an SVG preview.
That keeps geometry generation reliable and reviewable, and the spec itself is a
clean artifact for test engineers and for the RLHF feedback loop.

A typical vibration fixture (for mounting a test article to a shaker/slip table):
  - a base plate that bolts to the shaker head expander / slip table grid
  - a raised interface/boss pattern that the test article bolts onto
  - through-holes for both bolt patterns
Optional: stiffening ribs, a cavity to reduce mass, chamfered edges.

If CadQuery is unavailable (e.g. won't compile on Windows ARM), import of this
module fails gracefully and the app degrades to spec-only mode.
"""

from dataclasses import dataclass, field, asdict
from typing import List, Optional, Tuple
import os

try:
    import cadquery as cq
    from cadquery import exporters
    CADQUERY_AVAILABLE = True
    CADQUERY_IMPORT_ERROR = None
except Exception as e:  # noqa: BLE001
    CADQUERY_AVAILABLE = False
    CADQUERY_IMPORT_ERROR = str(e)


# --------------------------------------------------------------------------- #
# Structured fixture specification
# --------------------------------------------------------------------------- #
@dataclass
class BoltPattern:
    """A rectangular bolt-hole pattern (4 corners)."""
    spacing_x_mm: float
    spacing_y_mm: float
    hole_dia_mm: float


@dataclass
class FixtureSpec:
    """Everything needed to build a parametric fixture. Units: mm."""
    # Base plate (bolts to shaker table)
    base_length_mm: float = 200.0
    base_width_mm: float = 200.0
    base_thickness_mm: float = 15.0
    table_bolt_pattern: BoltPattern = field(
        default_factory=lambda: BoltPattern(160, 160, 9.0)
    )
    # Raised interface boss (test article mounts here)
    boss_length_mm: float = 100.0
    boss_width_mm: float = 100.0
    boss_height_mm: float = 25.0
    article_bolt_pattern: BoltPattern = field(
        default_factory=lambda: BoltPattern(80, 80, 5.5)
    )
    # Options
    edge_chamfer_mm: float = 3.0
    add_ribs: bool = True
    rib_thickness_mm: float = 8.0
    material: str = "6061-T6 Aluminum"
    # Free-text design rationale from the LLM (for the report + review)
    rationale: str = ""

    def to_dict(self) -> dict:
        d = asdict(self)
        return d

    def estimated_mass_kg(self) -> float:
        """Rough solid-volume mass estimate (ignores holes/cavities)."""
        # density kg/mm^3 for common materials
        density = {
            "6061-T6 Aluminum": 2.70e-6,
            "7075-T6 Aluminum": 2.81e-6,
            "Steel": 7.85e-6,
            "Stainless Steel": 8.00e-6,
            "Magnesium": 1.74e-6,
        }.get(self.material, 2.70e-6)
        base_v = self.base_length_mm * self.base_width_mm * self.base_thickness_mm
        boss_v = self.boss_length_mm * self.boss_width_mm * self.boss_height_mm
        return round((base_v + boss_v) * density, 3)


# --------------------------------------------------------------------------- #
# Geometry builder
# --------------------------------------------------------------------------- #
def build_fixture(spec: FixtureSpec):
    """Return a CadQuery Workplane solid for the given spec."""
    if not CADQUERY_AVAILABLE:
        raise RuntimeError(
            f"CadQuery not available: {CADQUERY_IMPORT_ERROR}"
        )

    # Base plate
    fixture = (
        cq.Workplane("XY")
        .box(spec.base_length_mm, spec.base_width_mm, spec.base_thickness_mm)
    )

    # Table bolt holes (through the base plate)
    tbp = spec.table_bolt_pattern
    fixture = (
        fixture.faces(">Z").workplane()
        .rect(tbp.spacing_x_mm, tbp.spacing_y_mm, forConstruction=True)
        .vertices()
        .hole(tbp.hole_dia_mm)
    )

    # Raised interface boss, centered on top of the base plate
    boss = (
        cq.Workplane("XY")
        .workplane(offset=spec.base_thickness_mm / 2.0)
        .box(spec.boss_length_mm, spec.boss_width_mm, spec.boss_height_mm,
             centered=(True, True, False))
    )
    fixture = fixture.union(boss)

    # Article bolt holes (into the top of the boss)
    abp = spec.article_bolt_pattern
    top_z = spec.base_thickness_mm / 2.0 + spec.boss_height_mm
    fixture = (
        fixture.faces(">Z").workplane()
        .rect(abp.spacing_x_mm, abp.spacing_y_mm, forConstruction=True)
        .vertices()
        .hole(abp.hole_dia_mm, depth=spec.boss_height_mm * 0.8)
    )

    # Optional stiffening ribs (two crossing ribs under the boss footprint)
    if spec.add_ribs:
        try:
            rib_h = spec.boss_height_mm
            rib_x = (
                cq.Workplane("XY").workplane(offset=spec.base_thickness_mm / 2.0)
                .box(spec.base_length_mm * 0.9, spec.rib_thickness_mm, rib_h,
                     centered=(True, True, False))
            )
            rib_y = (
                cq.Workplane("XY").workplane(offset=spec.base_thickness_mm / 2.0)
                .box(spec.rib_thickness_mm, spec.base_width_mm * 0.9, rib_h,
                     centered=(True, True, False))
            )
            fixture = fixture.union(rib_x).union(rib_y).union(boss)
            # re-cut article holes after rib union (union can cover them)
            fixture = (
                fixture.faces(">Z").workplane()
                .rect(abp.spacing_x_mm, abp.spacing_y_mm, forConstruction=True)
                .vertices()
                .hole(abp.hole_dia_mm, depth=spec.boss_height_mm * 0.8)
            )
        except Exception:
            pass  # ribs are a nicety; never fail the whole build over them

    # Chamfer the outer top edges of the base plate for a finished look
    if spec.edge_chamfer_mm and spec.edge_chamfer_mm > 0:
        try:
            fixture = fixture.edges("|Z").chamfer(spec.edge_chamfer_mm)
        except Exception:
            pass

    return fixture


def export_fixture(spec: FixtureSpec, out_dir: str, basename: str = "fixture"
                   ) -> dict:
    """
    Build and export the fixture. Returns paths dict:
      {"step": ..., "stl": ..., "svg": ..., "mass_kg": ...}
    """
    os.makedirs(out_dir, exist_ok=True)
    fixture = build_fixture(spec)

    paths = {}
    step_path = os.path.join(out_dir, f"{basename}.step")
    stl_path = os.path.join(out_dir, f"{basename}.stl")
    svg_path = os.path.join(out_dir, f"{basename}.svg")

    exporters.export(fixture, step_path)
    exporters.export(fixture, stl_path)
    exporters.export(
        fixture, svg_path,
        opt={"width": 520, "height": 380, "showAxes": False,
             "projectionDir": (1, 1, 1), "strokeWidth": 0.5},
    )

    paths["step"] = step_path
    paths["stl"] = stl_path
    paths["svg"] = svg_path
    paths["mass_kg"] = spec.estimated_mass_kg()
    return paths