| from __future__ import annotations |
|
|
| from typing import Any |
|
|
| from .cadquery_builder import build_cadquery_artifact |
| from .models import Design, Feature, Hole, ToolAction |
| from .solver3d import solve_3d_linear_elasticity |
|
|
|
|
| def _feature_family(design: Design) -> str: |
| feature_types = {feature.type for feature in design.features} |
| if "tabletop" in feature_types: |
| return "table" |
| if "stator_ring" in feature_types: |
| return "motor_stator" |
| if "seat_panel" in feature_types: |
| return "chair" |
| if "clamp_jaw" in feature_types: |
| return "torque_clamp" |
| if "hook_curve" in feature_types: |
| return "wall_hook" |
| if feature_types & {"generic_panel", "support_tube", "curved_tube", "flat_foot", "armrest", "headrest"}: |
| return "freeform_object" |
| return "bracket" |
|
|
|
|
| def _design_bounds(design: Design) -> dict[str, float]: |
| xs = [0.0, design.base_length_mm, design.load_point_x_mm] |
| ys = [-design.base_width_mm / 2, design.base_width_mm / 2, design.load_point_y_mm] |
| zs = [0.0, design.base_thickness_mm] |
| for feature in design.features: |
| xs.extend([feature.x, feature.x2]) |
| ys.extend([feature.y, feature.y2]) |
| zs.extend([feature.height, feature.radius]) |
| return { |
| "min_x": round(min(xs), 3), |
| "max_x": round(max(xs), 3), |
| "min_y": round(min(ys), 3), |
| "max_y": round(max(ys), 3), |
| "min_z": 0.0, |
| "max_z": round(max(zs), 3), |
| } |
|
|
|
|
| def design_family_template(family: str) -> Design: |
| if family.startswith("blank_"): |
| design = design_family_template(family.removeprefix("blank_")) |
| design.title = "Blank " + design.title |
| design.rationale = "Initialized as a blank family envelope; later tool calls add visible CAD features." |
| design.features = [] |
| return design |
|
|
| if family == "wall_hook": |
| return Design( |
| title="Wall-mounted J hook for 120 N hanging load", |
| rationale="A compact wall plate carries two mounting bolts while a thick J-shaped hook tube carries the hanging load at its curled tip.", |
| material="aluminum_6061", |
| load_newtons=120, |
| load_point_x_mm=62, |
| load_point_y_mm=0, |
| base_length_mm=74, |
| base_width_mm=54, |
| base_thickness_mm=6, |
| fixed_holes=[Hole(x=10, y=-15, radius=3), Hole(x=10, y=15, radius=3)], |
| features=[ |
| Feature(type="hook_curve", x=12, y=0, x2=68, y2=0, width=8, height=34, radius=10, note="round J hook tube"), |
| Feature(type="boss", x=62, y=0, height=1, radius=4, note="hook lip/load contact proxy"), |
| ], |
| expected_failure_mode="Bending at hook root and mounting-hole bearing stress.", |
| action_plan=["Create wall mounting plate", "Extrude curved hook tube", "Thicken root boss", "Run FEA proxy", "Commit hook geometry"], |
| ) |
|
|
| if family == "torque_clamp": |
| return Design( |
| title="Split clamp fixture for 120 Nm shaft torque", |
| rationale="Two jaws wrap a shaft proxy while a fixed root resists torque through a compact shear path.", |
| material="aluminum_6061", |
| load_newtons=120, |
| load_point_x_mm=68, |
| load_point_y_mm=0, |
| base_length_mm=92, |
| base_width_mm=58, |
| base_thickness_mm=8, |
| fixed_holes=[Hole(x=12, y=-18, radius=3), Hole(x=12, y=18, radius=3)], |
| features=[ |
| Feature(type="clamp_jaw", x=34, y=-18, x2=78, y2=-18, width=10, height=18, radius=9, note="lower clamp jaw"), |
| Feature(type="clamp_jaw", x=34, y=18, x2=78, y2=18, width=10, height=18, radius=9, note="upper clamp jaw"), |
| Feature(type="boss", x=66, y=0, height=12, radius=12, note="shaft torque proxy"), |
| ], |
| expected_failure_mode="Jaw bending and root shear under torque proxy load.", |
| action_plan=["Create split clamp jaws", "Place shaft proxy", "Add fixed root holes", "Run torque proxy FEA", "Commit clamp"], |
| ) |
|
|
| if family == "motor_stator": |
| return Design( |
| title="12-slot axial motor stator concept", |
| rationale="A circular stator ring with twelve teeth gives a recognizable motor design that can later connect to electromagnetic and thermal solvers.", |
| material="steel_1018", |
| load_newtons=80, |
| load_point_x_mm=52, |
| load_point_y_mm=0, |
| base_length_mm=96, |
| base_width_mm=96, |
| base_thickness_mm=8, |
| fixed_holes=[], |
| features=[ |
| Feature(type="stator_ring", x=48, y=0, width=14, height=8, radius=34, note="lamination ring"), |
| Feature(type="stator_tooth", x=48, y=0, width=9, height=18, radius=12, note="12 radial teeth"), |
| Feature(type="boss", x=48, y=0, height=8, radius=6, note="center shaft/load proxy"), |
| ], |
| expected_failure_mode="Thermal expansion and tooth/root stress are future solver targets.", |
| action_plan=["Create stator ring", "Add radial teeth", "Add center shaft proxy", "Run structural proxy", "Commit stator"], |
| ) |
|
|
| if family == "chair": |
| return Design( |
| title="Simple full chair with backrest", |
| rationale="A rectangular seat panel, four splayed legs, crossbars, and a backrest give a simple recognizable chair for load and deflection tests.", |
| material="aluminum_6061", |
| load_newtons=700, |
| load_point_x_mm=45, |
| load_point_y_mm=0, |
| base_length_mm=90, |
| base_width_mm=70, |
| base_thickness_mm=6, |
| fixed_holes=[], |
| features=[ |
| Feature(type="seat_panel", x=45, y=0, width=90, height=6, radius=0, note="seat panel"), |
| Feature(type="chair_leg", x=14, y=-24, x2=6, y2=-32, width=6, height=55, radius=0, note="front left leg"), |
| Feature(type="chair_leg", x=76, y=-24, x2=84, y2=-32, width=6, height=55, radius=0, note="front right leg"), |
| Feature(type="chair_leg", x=14, y=24, x2=6, y2=32, width=6, height=55, radius=0, note="rear left leg"), |
| Feature(type="chair_leg", x=76, y=24, x2=84, y2=32, width=6, height=55, radius=0, note="rear right leg"), |
| Feature(type="chair_back", x=45, y=31, width=84, height=44, radius=0, note="upright backrest panel"), |
| Feature(type="chair_crossbar", x=45, y=-30, width=84, height=4, radius=0, note="front leg crossbar"), |
| ], |
| expected_failure_mode="Leg buckling and seat deflection under distributed downward load.", |
| action_plan=["Create seat", "Add four legs", "Add backrest", "Apply distributed load proxy", "Run structural proxy", "Commit chair"], |
| ) |
|
|
| if family == "table": |
| return Design( |
| title="Small freeform table", |
| rationale="A tabletop plus individually added legs and stretchers forms a table from primitive CAD features.", |
| material="aluminum_6061", |
| load_newtons=500, |
| load_point_x_mm=50, |
| load_point_y_mm=0, |
| base_length_mm=100, |
| base_width_mm=70, |
| base_thickness_mm=6, |
| fixed_holes=[], |
| features=[ |
| Feature(type="tabletop", x=50, y=0, width=100, height=6, radius=4, note="small tabletop panel"), |
| Feature(type="table_leg", x=12, y=-28, width=6, height=48, radius=3, note="table leg"), |
| Feature(type="table_leg", x=88, y=-28, width=6, height=48, radius=3, note="table leg"), |
| Feature(type="table_leg", x=12, y=28, width=6, height=48, radius=3, note="table leg"), |
| Feature(type="table_leg", x=88, y=28, width=6, height=48, radius=3, note="table leg"), |
| ], |
| expected_failure_mode="Tabletop bending and leg buckling under downward load.", |
| action_plan=["Create tabletop", "Add requested legs", "Add stretchers", "Apply distributed load proxy", "Run structural proxy"], |
| ) |
|
|
| if family == "freeform_object": |
| return Design( |
| title="Freeform primitive CAD object", |
| rationale="A blank primitive grammar object; the agent composes panels, tubes, curves, feet, and decorative elements from the prompt.", |
| material="aluminum_6061", |
| load_newtons=120, |
| load_point_x_mm=50, |
| load_point_y_mm=0, |
| base_length_mm=100, |
| base_width_mm=70, |
| base_thickness_mm=6, |
| fixed_holes=[], |
| features=[], |
| expected_failure_mode="Depends on primitive layout and load path.", |
| action_plan=["Compose primitive CAD features", "Apply load", "Run FEA", "Iterate"], |
| ) |
|
|
| return Design( |
| title=f"{family.replace('_', ' ').title()}", |
| rationale="Initialized from a ribbed cantilever design family template.", |
| fixed_holes=[Hole(x=12, y=-13, radius=3), Hole(x=12, y=13, radius=3)], |
| features=[ |
| Feature(type="boss", x=90, y=0, height=7, radius=6, note="default load boss"), |
| ], |
| ) |
|
|
|
|
| def apply_action(design: Design | None, action: ToolAction, prompt: str = "") -> tuple[Design | None, dict[str, Any]]: |
| """Apply one agent tool action to the parametric design state.""" |
|
|
| if action.tool == "create_design_family": |
| family = action.params.get("family", "ribbed_cantilever_bracket") |
| design = design_family_template(str(family)) |
| prompt_text = prompt.lower() |
| if "chair" in str(family) and ( |
| "curv" in prompt_text |
| or "round" in prompt_text |
| or "organic" in prompt_text |
| or "sweep" in prompt_text |
| or "arched" in prompt_text |
| or "bent" in prompt_text |
| or " flowing" in prompt_text |
| or prompt_text.strip().startswith("flow ") |
| ): |
| design.title = "Blank curvy full chair with arched backrest" |
| design.rationale = "Initialized as a curvy chair envelope; later tools add rounded seat, splayed tubular legs, curved crossbars, and an arched backrest." |
| return design, {"valid": True, "message": f"Created design family {family}."} |
|
|
| if design is None: |
| return design, {"valid": False, "error": f"Tool {action.tool} requires an active design."} |
|
|
| if action.tool == "set_material": |
| design.material = action.params.get("material", design.material) |
| return design, {"valid": True, "changed_parameters": ["material"]} |
|
|
| if action.tool == "set_envelope": |
| for key, attr in [("length_mm", "base_length_mm"), ("width_mm", "base_width_mm"), ("thickness_mm", "base_thickness_mm")]: |
| if key in action.params: |
| setattr(design, attr, float(action.params[key])) |
| return design, {"valid": True, "changed_parameters": ["base_length_mm", "base_width_mm", "base_thickness_mm"]} |
|
|
| if action.tool == "set_load": |
| vector = action.params.get("vector_n", [0, 0, -design.load_newtons]) |
| point = action.params.get("point_mm") or action.params.get("point") or [design.load_point_x_mm, design.load_point_y_mm, design.base_thickness_mm] |
| design.load_newtons = abs(float(vector[2] if len(vector) > 2 else vector[-1])) |
| design.load_point_x_mm = float(point[0]) |
| design.load_point_y_mm = float(point[1]) |
| return design, {"valid": True, "changed_parameters": ["load_newtons", "load_point_x_mm", "load_point_y_mm"]} |
|
|
| if action.tool == "add_mount_hole": |
| center = action.params.get("center", [action.params.get("x", 12), action.params.get("y", 0)]) |
| design.fixed_holes.append(Hole(x=float(center[0]), y=float(center[1]), radius=float(action.params.get("radius_mm", action.params.get("radius", 3))))) |
| return design, {"valid": True, "changed_parameters": ["fixed_holes"]} |
|
|
| if action.tool == "add_rib": |
| start = action.params.get("start", [action.params.get("x", 10), action.params.get("y", 0), 0]) |
| end = action.params.get("end", [action.params.get("x2", 90), action.params.get("y2", 0), 10]) |
| design.features.append( |
| Feature( |
| type="rib", |
| x=float(start[0]), |
| y=float(start[1]), |
| x2=float(end[0]), |
| y2=float(end[1]), |
| width=float(action.params.get("width_mm", action.params.get("width", 4))), |
| height=float(action.params.get("height_mm", action.params.get("height", max(end[2] if len(end) > 2 else 12, 1)))), |
| note=str(action.params.get("id", "agent rib")), |
| ) |
| ) |
| return design, {"valid": True, "changed_parameters": ["features"]} |
|
|
| if action.tool == "add_lightening_hole": |
| center = action.params.get("center", [action.params.get("x", 50), action.params.get("y", 0), 0]) |
| design.features.append( |
| Feature( |
| type="lightening_hole", |
| x=float(center[0]), |
| y=float(center[1]), |
| radius=float(action.params.get("radius_mm", action.params.get("radius", 4))), |
| note=str(action.params.get("id", "agent lightening hole")), |
| ) |
| ) |
| return design, {"valid": True, "changed_parameters": ["features"]} |
|
|
| if action.tool == "add_feature": |
| params = dict(action.params) |
| feature_type = str(params.pop("type")) |
| design.features.append( |
| Feature( |
| type=feature_type, |
| x=float(params.get("x", 0)), |
| y=float(params.get("y", 0)), |
| x2=float(params.get("x2", 0)), |
| y2=float(params.get("y2", 0)), |
| width=float(params.get("width", params.get("width_mm", 0))), |
| height=float(params.get("height", params.get("height_mm", 0))), |
| radius=float(params.get("radius", params.get("radius_mm", 0))), |
| note=str(params.get("note", params.get("id", feature_type))), |
| ) |
| ) |
| return design, {"valid": True, "changed_parameters": ["features"], "added_feature": feature_type} |
|
|
| if action.tool == "observe_design": |
| return design, { |
| "valid": True, |
| "observation": { |
| "family": _feature_family(design), |
| "feature_count": len(design.features), |
| "fixed_hole_count": len(design.fixed_holes), |
| "bounds_mm": _design_bounds(design), |
| "load_point_mm": [design.load_point_x_mm, design.load_point_y_mm, design.base_thickness_mm], |
| "material": design.material, |
| }, |
| } |
|
|
| if action.tool == "measure_clearance": |
| bounds = _design_bounds(design) |
| return design, { |
| "valid": True, |
| "measurement": { |
| "bounds_mm": bounds, |
| "envelope_length_mm": round(bounds["max_x"] - bounds["min_x"], 3), |
| "envelope_width_mm": round(bounds["max_y"] - bounds["min_y"], 3), |
| "feature_count": len(design.features), |
| "load_is_inside_nominal_envelope": 0 <= design.load_point_x_mm <= design.base_length_mm |
| and -design.base_width_mm / 2 <= design.load_point_y_mm <= design.base_width_mm / 2, |
| }, |
| } |
|
|
| if action.tool == "check_constraints": |
| family = _feature_family(design) |
| missing: list[str] = [] |
| if family == "chair": |
| for required in ["seat_panel", "chair_leg", "chair_back"]: |
| if not any(feature.type == required for feature in design.features): |
| missing.append(required) |
| if sum(1 for feature in design.features if feature.type == "chair_leg") < 4: |
| missing.append("four_chair_legs") |
| if family == "torque_clamp" and sum(1 for feature in design.features if feature.type == "clamp_jaw") < 2: |
| missing.append("two_split_clamp_jaws") |
| if family == "motor_stator" and not any(feature.type == "stator_tooth" for feature in design.features): |
| missing.append("radial_stator_teeth") |
| return design, {"valid": True, "family": family, "constraint_status": "pass" if not missing else "needs_repair", "missing": missing} |
|
|
| if action.tool == "visual_snapshot": |
| return design, { |
| "valid": True, |
| "snapshot": { |
| "view": action.params.get("view", "isometric"), |
| "camera": action.params.get("camera", "auto"), |
| "note": "Renderer should inspect this view for identity, load contact, and disconnected geometry.", |
| "family": _feature_family(design), |
| "bounds_mm": _design_bounds(design), |
| }, |
| } |
|
|
| if action.tool == "critique_geometry": |
| family = _feature_family(design) |
| notes = [] |
| if family == "torque_clamp": |
| notes.append("Blue cylinder is shaft/load proxy; green arcs and jaws should visibly wrap it with a small split gap.") |
| if family == "chair": |
| notes.append("Seat, four legs, crossbars, and backrest should be visible from isometric and side views.") |
| if family == "motor_stator": |
| notes.append("Stator should read as a flat toothed annulus with visible center bore and radial slots.") |
| if family == "wall_hook": |
| notes.append("Hook tip must be the load contact; load arrow should terminate on the curved hook.") |
| return design, {"valid": True, "critique": notes or ["Generic bracket load path should remain connected."]} |
|
|
| if action.tool == "run_fea": |
| return design, {"valid": True, "simulation": solve_3d_linear_elasticity(design, prompt)} |
|
|
| if action.tool == "export_cadquery": |
| return design, build_cadquery_artifact(design) |
|
|
| if action.tool == "commit_design": |
| return design, {"valid": True, "committed": True, "simulation": solve_3d_linear_elasticity(design, prompt)} |
|
|
| return design, {"valid": False, "error": f"Unknown tool: {action.tool}"} |
|
|
|
|
| def run_actions(actions: list[ToolAction], prompt: str = "", initial_design: Design | None = None) -> dict[str, Any]: |
| design: Design | None = initial_design |
| trace = [] |
| last_result: dict[str, Any] = {} |
| for idx, action in enumerate(actions, start=1): |
| design, result = apply_action(design, action, prompt) |
| last_result = result |
| trace.append({"step": idx, "action": action.model_dump(), "result": result, "design": design.model_dump() if design else None}) |
| if result.get("committed"): |
| break |
| return {"design": design.model_dump() if design else None, "last_result": last_result, "trace": trace} |
|
|