| from __future__ import annotations |
|
|
| import math |
| import re |
| import time |
| from pathlib import Path |
| from typing import Any |
|
|
| from .models import Design |
|
|
|
|
| def _slug(value: str) -> str: |
| return re.sub(r"[^a-z0-9]+", "_", value.lower()).strip("_")[:64] or "design" |
|
|
|
|
| def _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", "table_leg"}: |
| return "freeform_object" |
| return "bracket" |
|
|
|
|
| def _box(cq: Any, x: float, y: float, z: float, cx: float, cy: float, cz: float): |
| return cq.Workplane("XY").box(x, y, z).translate((cx, cy, cz)) |
|
|
|
|
| def _cylinder_x(cq: Any, radius: float, length: float, cx: float, cy: float, cz: float): |
| return cq.Workplane("YZ").circle(radius).extrude(length).translate((cx - length / 2, cy, cz)) |
|
|
|
|
| def _cylinder_y(cq: Any, radius: float, length: float, cx: float, cy: float, cz: float): |
| return cq.Workplane("XZ").circle(radius).extrude(length).translate((cx, cy - length / 2, cz)) |
|
|
|
|
| def _cylinder_z(cq: Any, radius: float, height: float, cx: float, cy: float, cz: float): |
| return cq.Workplane("XY").circle(radius).extrude(height).translate((cx, cy, cz - height / 2)) |
|
|
|
|
| def _merge(parts: list[Any]): |
| body = parts[0] |
| for part in parts[1:]: |
| body = body.union(part) |
| return body |
|
|
|
|
| def _box_between_xy(cq: Any, x1: float, y1: float, x2: float, y2: float, width: float, height: float, z: float): |
| length = max(math.hypot(x2 - x1, y2 - y1), 1) |
| part = _box(cq, length, max(width, 1), max(height, 1), (x1 + x2) / 2, (y1 + y2) / 2, z) |
| return part.rotate(((x1 + x2) / 2, (y1 + y2) / 2, z), ((x1 + x2) / 2, (y1 + y2) / 2, z + 1), math.degrees(math.atan2(y2 - y1, x2 - x1))) |
|
|
|
|
| def _primitive_part(cq: Any, design: Design, feature: Any, ops: list[dict[str, Any]]): |
| if feature.type in {"tabletop", "generic_panel"}: |
| depth = max(design.base_width_mm, 18) |
| z = 52 if feature.type == "tabletop" else max(feature.height, 4) / 2 |
| ops.append({"op": "box_extrude", "part": feature.type, "center_xy_mm": [feature.x, feature.y], "size_mm": [max(feature.width, 24), depth, max(feature.height, 4)]}) |
| return _box(cq, max(feature.width, 24), depth, max(feature.height, 4), feature.x or design.base_length_mm / 2, feature.y, z) |
| if feature.type == "table_leg": |
| height = max(feature.height, 24) |
| radius = max(feature.radius, feature.width / 2, 2.4) |
| ops.append({"op": "cylinder_extrude", "part": "table_leg", "center_xy_mm": [feature.x, feature.y], "height_mm": height, "radius_mm": radius}) |
| return _cylinder_z(cq, radius, height, feature.x, feature.y, height / 2) |
| if feature.type == "support_tube": |
| z = max(feature.height, 8) |
| ops.append({"op": "box_between", "part": "support_tube", "from_xy_mm": [feature.x, feature.y], "to_xy_mm": [feature.x2, feature.y2]}) |
| return _box_between_xy(cq, feature.x, feature.y, feature.x2, feature.y2, max(feature.width, feature.radius * 2, 3), max(feature.radius * 2, 3), z) |
| if feature.type == "curved_tube": |
| z = max(feature.height, 18) |
| ops.append({"op": "segmented_curve_proxy", "part": "curved_tube", "from_xy_mm": [feature.x, feature.y], "to_xy_mm": [feature.x2, feature.y2]}) |
| return _box_between_xy(cq, feature.x, feature.y, feature.x2, feature.y2, max(feature.width, feature.radius * 2, 3), max(feature.radius * 2, 3), z) |
| if feature.type == "flat_foot": |
| ops.append({"op": "box_extrude", "part": "flat_foot", "center_xy_mm": [feature.x, feature.y]}) |
| return _box(cq, max(feature.width, 16), max(feature.radius * 2.4, 8), max(feature.height, 2.5), feature.x, feature.y, max(feature.height, 2.5) / 2) |
| return None |
|
|
|
|
| def _build_stator(cq: Any, design: Design, ops: list[dict[str, Any]]): |
| ring = next((item for item in design.features if item.type == "stator_ring"), None) |
| tooth = next((item for item in design.features if item.type == "stator_tooth"), None) |
| cx = ring.x if ring else design.base_length_mm / 2 |
| cy = ring.y if ring else 0 |
| outer = ring.radius + ring.width if ring else 46 |
| inner = max((tooth.radius if tooth else 18), 12) |
| height = max(ring.height if ring else design.base_thickness_mm, 4) |
|
|
| body = cq.Workplane("XY").circle(outer).circle(inner).extrude(height) |
| ops.append({"op": "sketch_annulus", "outer_radius_mm": outer, "inner_radius_mm": inner, "height_mm": height}) |
| tooth_count = 12 |
| for idx in range(tooth_count): |
| angle = 360 * idx / tooth_count |
| length = max((tooth.height if tooth else 18), 8) |
| width = max((tooth.width if tooth else 8), 3) |
| radial = inner + length / 2 |
| local = cq.Workplane("XY").box(length, width, height).translate((radial, 0, height / 2)) |
| local = local.rotate((0, 0, 0), (0, 0, 1), angle).translate((cx, cy, 0)) |
| body = body.union(local) |
| ops.append({"op": "union_radial_tooth", "index": idx + 1, "angle_deg": angle, "length_mm": length, "width_mm": width}) |
| body = body.translate((cx, cy, 0)) |
| ops.append({"op": "export_ready_body", "family": "motor_stator", "note": "flat annular stator, not a torus placeholder"}) |
| return body |
|
|
|
|
| def _build_chair(cq: Any, design: Design, ops: list[dict[str, Any]]): |
| parts = [] |
| seat_z = 48 |
| parts.append(_box(cq, design.base_length_mm, design.base_width_mm, design.base_thickness_mm, design.base_length_mm / 2, 0, seat_z)) |
| ops.append({"op": "box_extrude", "part": "seat_panel", "size_mm": [design.base_length_mm, design.base_width_mm, design.base_thickness_mm]}) |
| for leg in [item for item in design.features if item.type == "chair_leg"]: |
| height = max(leg.height, 35) |
| parts.append(_box(cq, max(leg.width, 4), max(leg.width, 4), height, leg.x, leg.y, height / 2)) |
| ops.append({"op": "extrude_leg", "part": leg.note, "top_xy_mm": [leg.x, leg.y], "height_mm": height}) |
| parts.append(_box(cq, design.base_length_mm, 5, 42, design.base_length_mm / 2, design.base_width_mm / 2 - 3, seat_z + 22)) |
| parts.append(_box(cq, 5, 5, 54, 10, design.base_width_mm / 2 - 3, seat_z + 25)) |
| parts.append(_box(cq, 5, 5, 54, design.base_length_mm - 10, design.base_width_mm / 2 - 3, seat_z + 25)) |
| ops.append({"op": "add_backrest", "parts": ["back_panel", "left_back_post", "right_back_post"]}) |
| decorative = [item for item in design.features if item.type == "decorative_curve"] |
| if decorative: |
| back_y = design.base_width_mm / 2 + 0.5 |
| cx = design.base_length_mm / 2 |
| cz = seat_z + 38 |
| if any("flower" in item.note.lower() or "petal" in item.note.lower() for item in decorative): |
| parts.append(_cylinder_y(cq, 3.2, 1.8, cx, back_y, cz)) |
| for idx in range(6): |
| angle = math.tau * idx / 6 |
| px = cx + math.cos(angle) * 10 |
| pz = cz + math.sin(angle) * 10 |
| parts.append(_cylinder_y(cq, 5.5, 1.6, px, back_y, pz)) |
| ops.append({"op": "union_flower_petal_disc", "index": idx + 1, "center_xz_mm": [round(px, 3), round(pz, 3)]}) |
| parts.append(_cylinder_y(cq, 1.6, 1.6, cx - 2, back_y, seat_z + 18)) |
| ops.append({"op": "add_backrest_flower_pattern", "note": "decorative raised petal discs on backrest"}) |
| else: |
| for idx, item in enumerate(decorative, start=1): |
| parts.append(_cylinder_y(cq, max(item.radius, 2), 1.4, item.x or cx, back_y, seat_z + 24 + idx * 8)) |
| ops.append({"op": "union_decorative_curve_proxy", "index": idx, "note": item.note}) |
| for item in [feature for feature in design.features if feature.type in {"armrest", "headrest", "flat_foot", "support_tube", "curved_tube"}]: |
| primitive = _primitive_part(cq, design, item, ops) |
| if primitive is not None: |
| parts.append(primitive) |
| ops.append({"op": "export_ready_body", "family": "chair"}) |
| return _merge(parts) |
|
|
|
|
| def _build_table(cq: Any, design: Design, ops: list[dict[str, Any]]): |
| parts = [] |
| for feature in design.features: |
| primitive = _primitive_part(cq, design, feature, ops) |
| if primitive is not None: |
| parts.append(primitive) |
| if not parts: |
| parts.append(_box(cq, design.base_length_mm, design.base_width_mm, design.base_thickness_mm, design.base_length_mm / 2, 0, 52)) |
| ops.append({"op": "box_extrude", "part": "fallback_tabletop"}) |
| ops.append({"op": "export_ready_body", "family": "table"}) |
| return _merge(parts) |
|
|
|
|
| def _build_freeform(cq: Any, design: Design, ops: list[dict[str, Any]]): |
| parts = [] |
| for feature in design.features: |
| primitive = _primitive_part(cq, design, feature, ops) |
| if primitive is not None: |
| parts.append(primitive) |
| if not parts: |
| parts.append(_box(cq, design.base_length_mm, design.base_width_mm, design.base_thickness_mm, design.base_length_mm / 2, 0, design.base_thickness_mm / 2)) |
| ops.append({"op": "box_extrude", "part": "fallback_freeform_panel"}) |
| ops.append({"op": "export_ready_body", "family": "freeform_object"}) |
| return _merge(parts) |
|
|
|
|
| def _build_clamp(cq: Any, design: Design, ops: list[dict[str, Any]]): |
| parts = [ |
| _box(cq, 16, design.base_width_mm, 28, 8, 0, 14), |
| _cylinder_x(cq, 11, 58, 58, 0, 22), |
| ] |
| ops.append({"op": "box_extrude", "part": "fixed_root", "size_mm": [16, design.base_width_mm, 28]}) |
| ops.append({"op": "cylinder_extrude", "part": "shaft_bore_proxy", "axis": "x", "radius_mm": 11, "length_mm": 58}) |
| for y in [-18, 18]: |
| parts.append(_box(cq, 54, 10, 15, 53, y, 22)) |
| ops.append({"op": "box_extrude", "part": "split_clamp_jaw", "center_y_mm": y, "size_mm": [54, 10, 15]}) |
| parts.append(_cylinder_x(cq, 14, 12, 82, 0, 22)) |
| ops.append({"op": "add_end_collar", "radius_mm": 14, "note": "visual collar for torque/load application"}) |
| ops.append({"op": "export_ready_body", "family": "torque_clamp"}) |
| return _merge(parts) |
|
|
|
|
| def _build_hook(cq: Any, design: Design, ops: list[dict[str, Any]]): |
| wall_h = max(design.base_width_mm, 50) |
| parts = [_box(cq, 8, 26, wall_h, 4, 0, wall_h / 2)] |
| ops.append({"op": "box_extrude", "part": "wall_plate", "size_mm": [8, 26, wall_h]}) |
| tube = 4 |
| root_z = wall_h * 0.58 |
| points = [(8, root_z), (32, root_z), (52, root_z - 8), (58, root_z - 28), (44, root_z - 38), (35, root_z - 24)] |
| for idx, ((x1, z1), (x2, z2)) in enumerate(zip(points, points[1:]), start=1): |
| length = math.hypot(x2 - x1, z2 - z1) |
| angle = math.degrees(math.atan2(z2 - z1, x2 - x1)) |
| segment = _cylinder_x(cq, tube, length, (x1 + x2) / 2, 0, (z1 + z2) / 2) |
| segment = segment.rotate(((x1 + x2) / 2, 0, (z1 + z2) / 2), ((x1 + x2) / 2, 1, (z1 + z2) / 2), -angle) |
| parts.append(segment) |
| ops.append({"op": "sweep_hook_segment", "index": idx, "from_xz_mm": [x1, z1], "to_xz_mm": [x2, z2], "tube_radius_mm": tube}) |
| ops.append({"op": "export_ready_body", "family": "wall_hook"}) |
| return _merge(parts) |
|
|
|
|
| def _build_bracket(cq: Any, design: Design, ops: list[dict[str, Any]]): |
| body = _box(cq, design.base_length_mm, design.base_width_mm, design.base_thickness_mm, design.base_length_mm / 2, 0, design.base_thickness_mm / 2) |
| ops.append({"op": "box_extrude", "part": "base_plate", "size_mm": [design.base_length_mm, design.base_width_mm, design.base_thickness_mm]}) |
| for feature in design.features: |
| if feature.type == "rib": |
| length = max(math.hypot(feature.x2 - feature.x, feature.y2 - feature.y), 1) |
| rib = _box(cq, length, max(feature.width, 1), max(feature.height, 1), (feature.x + feature.x2) / 2, (feature.y + feature.y2) / 2, design.base_thickness_mm + max(feature.height, 1) / 2) |
| rib = rib.rotate((feature.x, feature.y, design.base_thickness_mm), (feature.x, feature.y, design.base_thickness_mm + 1), math.degrees(math.atan2(feature.y2 - feature.y, feature.x2 - feature.x))) |
| body = body.union(rib) |
| ops.append({"op": "union_rib", "from_xy_mm": [feature.x, feature.y], "to_xy_mm": [feature.x2, feature.y2]}) |
| if feature.type == "boss": |
| body = body.union(_cylinder_z(cq, max(feature.radius, 1), max(feature.height, 1), feature.x, feature.y, design.base_thickness_mm + max(feature.height, 1) / 2)) |
| ops.append({"op": "union_boss", "center_xy_mm": [feature.x, feature.y], "radius_mm": feature.radius}) |
| ops.append({"op": "export_ready_body", "family": "bracket"}) |
| return body |
|
|
|
|
| def build_cadquery_artifact(design: Design, output_root: str | Path = "runs") -> dict[str, Any]: |
| """Build an actual CadQuery body and export manufacturable artifacts.""" |
|
|
| operations: list[dict[str, Any]] = [] |
| family = _family(design) |
| output_dir = Path(output_root) / f"{int(time.time() * 1000)}_{_slug(design.title)}" |
| output_dir.mkdir(parents=True, exist_ok=True) |
|
|
| try: |
| import cadquery as cq |
|
|
| builders = { |
| "motor_stator": _build_stator, |
| "chair": _build_chair, |
| "table": _build_table, |
| "freeform_object": _build_freeform, |
| "torque_clamp": _build_clamp, |
| "wall_hook": _build_hook, |
| "bracket": _build_bracket, |
| } |
| body = builders[family](cq, design, operations) |
| step_path = output_dir / "design.step" |
| stl_path = output_dir / "design.stl" |
| cq.exporters.export(body, str(step_path)) |
| cq.exporters.export(body, str(stl_path)) |
| return { |
| "valid": True, |
| "family": family, |
| "engine": "cadquery", |
| "operations": operations, |
| "step_path": str(step_path), |
| "stl_path": str(stl_path), |
| } |
| except Exception as exc: |
| operations.append({"op": "cadquery_error", "error": str(exc)}) |
| return { |
| "valid": False, |
| "family": family, |
| "engine": "cadquery", |
| "operations": operations, |
| "error": str(exc), |
| } |
|
|