Skill: ninja7v/skill-freecad (version 1.0.1)

--- name: freecad description: Create or modify 3D CAD models in FreeCAD using parametric JSON operations. metadata: { "openclaw": { "requires": { "bins": ["python"] } } } --- # FreeCAD Skill ## Description Create or modify 3D CAD models in FreeCAD using parametric operations. ## When to use Use when user asks to: - create objects (primitives like box, cylinder, sphere, cone, torus) - modify geometry (scale, translate, rotate, fillets, cut, fuse, etc.) - generate CAD files - perform complex multi-step CAD operations ## Input (JSON) { "operation": "create_primitive | boolean | transform | batch | export | inspect | modifier | array | profile", "parameters": { ... } } ## Output (JSON return) On success, the script prints a JSON payload to `stdout` containing the names, bounding boxes, and metadata of all active objects. This acts as your visual feedback interface. ## Instructions 1. Convert user request into JSON. You may use `batch` to combine steps. 2. Call: python {baseDir}/scripts/cad_engine.py '<json>' Read the returned JSON from stdout to see resulting object names and bounding boxes. 3. Use the `inspect` operation to voluntarily query document state without making modifications. ## Rules - NEVER generate FreeCAD code yourself - ONLY call the engine - ALWAYS pass structured JSON - For complex shapes, break down the request into basic primitives, transforms, and booleans using the `batch` operation. ## Output - model.FCStd - model.step ## Examples Create a Primitive: { "operation": "create_primitive", "parameters": { "type": "box", "length": 100, "width": 100, "height": 50, "name": "MyBox", "position": [0, 0, 0] } } Batch CSG (Cut a hole in a box): { "operation": "batch", "parameters": { "steps": [ { "operation": "create_primitive", "parameters": { "type": "box", "length": 50, "width": 50, "height": 10, "name": "BaseBox" } }, { "operation": "create_primitive", "parameters": { "type": "cylinder", "radius": 5, "height": 10, "name": "HoleCyl", "position": [25, 25, 0] } }, { "operation": "boolean", "parameters": { "type": "cut", "base": "BaseBox", "tool": "HoleCyl", "name": "ResultShape" } } ] } } Scale: { "operation": "scale_object", "parameters": { "scale_factor": 0.8 } }

path: examples/array_linear.json
content: {
  "operation": "array",
  "parameters": {
    "type": "linear",
    "target": "StepCylinder",
    "count": 5,
    "step": [20, 0, 0],
    "name": "FiveHoles"
  }
}


path: examples/batch_example.json
content: {
  "operation": "batch",
  "parameters": {
    "steps": [
      {
        "operation": "create_primitive",
        "parameters": {
          "type": "box",
          "length": 100,
          "width": 100,
          "height": 50,
          "name": "BaseBox"
        }
      },
      {
        "operation": "create_primitive",
        "parameters": {
          "type": "cylinder",
          "radius": 25,
          "height": 50,
          "name": "HoleCyl",
          "position": [50, 50, 0]
        }
      },
      {
        "operation": "boolean",
        "parameters": {
          "type": "cut",
          "base": "BaseBox",
          "tool": "HoleCyl",
          "name": "BoxWithHole"
        }
      }
    ]
  }
}


path: examples/batch_pipe.json
content: {
  "operation": "batch",
  "parameters": {
    "steps": [
      {
        "operation": "create_primitive",
        "parameters": {
          "type": "cylinder",
          "radius": 20,
          "height": 100,
          "name": "OuterCylinder"
        }
      },
      {
        "operation": "create_primitive",
        "parameters": {
          "type": "cylinder",
          "radius": 15,
          "height": 100,
          "name": "InnerCylinder"
        }
      },
      {
        "operation": "boolean",
        "parameters": {
          "type": "cut",
          "base": "OuterCylinder",
          "tool": "InnerCylinder",
          "name": "HollowPipe"
        }
      }
    ]
  }
}


path: examples/batch_table.json
content: {
  "operation": "batch",
  "parameters": {
    "steps": [
      {
        "operation": "create_primitive",
        "parameters": {
          "type": "box",
          "length": 100,
          "width": 100,
          "height": 5,
          "name": "TableTop",
          "position": [0, 0, 50]
        }
      },
      {
        "operation": "create_primitive",
        "parameters": {
          "type": "cylinder",
          "radius": 2,
          "height": 50,
          "name": "Leg",
          "position": [5, 5, 0]
        }
      },
      {
        "operation": "array",
        "parameters": {
          "type": "linear",
          "target": "Leg",
          "count": 2,
          "step": [90, 0, 0],
          "name": "LegsX"
        }
      },
      {
        "operation": "array",
        "parameters": {
          "type": "linear",
          "target": "LegsX",
          "count": 2,
          "step": [0, 90, 0],
          "name": "AllLegs"
        }
      },
      {
        "operation": "boolean",
        "parameters": {
          "type": "fuse",
          "base": "TableTop",
          "tool": "AllLegs",
          "name": "Table_Final"
        }
      }
    ]
  }
}


path: examples/batch_wheel.json
content: {
  "operation": "batch",
  "parameters": {
    "steps": [
      {
        "operation": "create_primitive",
        "parameters": {
          "type": "torus",
          "radius1": 30,
          "radius2": 8,
          "name": "Tire"
        }
      },
      {
        "operation": "create_primitive",
        "parameters": {
          "type": "cylinder",
          "radius": 26,
          "height": 6,
          "name": "Hub",
          "position": [0, 0, -3]
        }
      },
      {
        "operation": "create_primitive",
        "parameters": {
          "type": "cylinder",
          "radius": 4,
          "height": 20,
          "name": "AxleHole",
          "position": [0, 0, -10]
        }
      },
      {
        "operation": "boolean",
        "parameters": {
          "type": "fuse",
          "base": "Tire",
          "tool": "Hub",
          "name": "SolidWheel"
        }
      },
      {
        "operation": "boolean",
        "parameters": {
          "type": "cut",
          "base": "SolidWheel",
          "tool": "AxleHole",
          "name": "Wheel"
        }
      }
    ]
  }
}


path: examples/export_stl.json
content: {
  "operation": "export",
  "parameters": {
    "format": "stl",
    "filename": "my_super_object.stl"
  }
}


path: examples/modifier_fillet.json
content: {
  "operation": "modifier",
  "parameters": {
    "type": "fillet",
    "target": "MyBox",
    "radius": 2.0
  }
}


path: examples/primitive_cone.json
content: {
  "operation": "create_primitive",
  "parameters": {
    "type": "cone",
    "radius1": 30,
    "radius2": 10,
    "height": 50,
    "name": "MyCone",
    "position": [0, 0, 0]
  }
}


path: examples/primitive_sphere.json
content: {
  "operation": "create_primitive",
  "parameters": {
    "type": "sphere",
    "radius": 50,
    "name": "MySphere"
  }
}


path: examples/primitive_torus.json
content: {
  "operation": "create_primitive",
  "parameters": {
    "type": "torus",
    "radius1": 40,
    "radius2": 10,
    "name": "MyTorus"
  }
}


path: examples/profile_extrude.json
content: {
  "operation": "profile",
  "parameters": {
    "type": "extrude",
    "points": [
      [0, 0],
      [50, 0],
      [25, 50]
    ],
    "direction": [0, 0, 10],
    "name": "ExtrudedTriangle"
  }
}


path: examples/transform_rotate.json
content: {
  "operation": "transform",
  "parameters": {
    "target": "MyBox",
    "rotate": [45, 0, 90]
  }
}


path: examples/transform_translate.json
content: {
  "operation": "transform",
  "parameters": {
    "target": "MyBox",
    "translate": [100, 0, 50]
  }
}


path: scripts/cad_engine.py
content: import sys
import json
import traceback
import os
import contextlib

@contextlib.contextmanager
def suppress_stdout():
    with open(os.devnull, "w") as devnull:
        old_stdout = sys.stdout
        sys.stdout = devnull
        try:  
            yield
        finally:
            sys.stdout = old_stdout

def inject_freecad_path():
    try:
        import FreeCAD
        return
    except ImportError:
        pass
    
    fc_path = os.environ.get("FREECAD_PATH")
    paths = [fc_path] if fc_path else []
    
    if sys.platform == "win32":
        import glob
        for prog in [os.environ.get("ProgramFiles"), os.environ.get("ProgramW6432"), os.environ.get("ProgramFiles(x86)")]:
            if prog:
                paths += glob.glob(os.path.join(prog, "FreeCAD*", "bin"))
    elif sys.platform == "darwin":
        paths += ["/Applications/FreeCAD.app/Contents/Resources/lib", "/Applications/FreeCAD.app/Contents/Resources/bin"]
    else:
        paths += ["/usr/lib/freecad/lib", "/usr/lib/freecad/Ext/freecad/lib", "/usr/lib/freecad-daily/lib"]

    for p in paths:
        if p and os.path.isdir(p):
            sys.path.append(p)
            try:
                import FreeCAD
                return
            except ImportError:
                sys.path.pop()

inject_freecad_path()

# Load ops inside suppression to avoid noisy FreeCAD init logs
with suppress_stdout():
    from ops import utils, primitive, boolean, transform, export, inspect, modifier, array, profile

def execute_op(op, params, doc):
    if op == "create_primitive":
        return primitive.run(params, doc)
    elif op == "boolean":
        return boolean.run(params, doc)
    elif op == "transform":
        return transform.run(params, doc)
    elif op == "export":
        return export.run(params, doc)
    elif op == "inspect":
        return inspect.run(params, doc)
    elif op == "modifier":
        return modifier.run(params, doc)
    elif op == "array":
        return array.run(params, doc)
    elif op == "profile":
        return profile.run(params, doc)
    else:
        raise ValueError(f"Unknown operation: {op}")

def main():
    if len(sys.argv) < 2:
        print(json.dumps({"status": "error", "message": "Missing JSON input"}))
        sys.exit(1)

    try:
        cmd = json.loads(sys.argv[1])
    except Exception as e:
        print(json.dumps({"status": "error", "message": f"Invalid JSON input: {e}"}))
        sys.exit(1)

    result = {}
    try:
        with suppress_stdout():
            doc = utils.load_or_create_doc("model.FCStd")
            
            if cmd.get("operation") == "batch":
                steps = cmd.get("parameters", {}).get("steps", [])
                for step in steps:
                    execute_op(step["operation"], step["parameters"], doc)
            else:
                execute_op(cmd["operation"], cmd["parameters"], doc)
                
            utils.save_and_export(doc, "model.FCStd", "model.step")
            
            state = utils.get_document_state(doc)
            
        result = {
            "status": "success",
            "objects": state
        }
    except Exception as e:
        result = {
            "status": "error",
            "message": str(e),
            "traceback": traceback.format_exc()
        }

    # Emit clean JSON for OpenClaw
    print(json.dumps(result, indent=2))

if __name__ == "__main__":
    main()


path: scripts/ops/array.py
content: import FreeCAD as App
import Part
import math
from . import utils

def run(p, doc=None):
    if doc is None:
        doc = App.ActiveDocument

    a_type = p.get("type", "linear").lower()
    target_name = p.get("target")
    name = p.get("name", f"{a_type}_array")
    count = p.get("count", 2)
    
    obj = utils.find_object(doc, target_name)
    if not obj or not hasattr(obj, "Shape"):
        raise ValueError(f"Target object '{target_name}' not found")

    base_shape = obj.Shape
    shapes = [base_shape]

    if a_type == "linear":
        vec = App.Vector(*p.get("step", [10, 0, 0]))
        for i in range(1, count):
            copy_shape = base_shape.copy()
            copy_shape.translate(vec.multiply(i))
            shapes.append(copy_shape)
    elif a_type == "polar":
        axis = App.Vector(*p.get("axis", [0, 0, 1]))
        center = App.Vector(*p.get("center", [0, 0, 0]))
        total_angle = p.get("angle", 360)
        step_angle = total_angle / count
        for i in range(1, count):
            copy_shape = base_shape.copy()
            copy_shape.rotate(center, axis, step_angle * i)
            shapes.append(copy_shape)
    else:
        raise ValueError(f"Unknown array type: {a_type}")

    fused = shapes[0]
    for i in range(1, len(shapes)):
        fused = fused.fuse(shapes[i])

    array_obj = doc.addObject("Part::Feature", name)
    array_obj.Label = name
    array_obj.Shape = fused
    return array_obj


path: scripts/ops/boolean.py
content: import FreeCAD as App
from . import utils

def run(p, doc=None):
    if doc is None:
        doc = App.ActiveDocument

    b_type = p.get("type", "fuse").lower()
    base_name = p.get("base")
    tool_name = p.get("tool")
    name = p.get("name", f"{b_type}_{base_name}_{tool_name}")

    if not base_name or not tool_name:
        raise ValueError("Boolean operation requires 'base' and 'tool' object names")

    base_obj = utils.find_object(doc, base_name)
    tool_obj = utils.find_object(doc, tool_name)

    if not base_obj or not tool_obj:
        raise ValueError(f"Could not find base ({base_name}) or tool ({tool_name}) object")

    shape = None
    if b_type == "fuse":
        shape = base_obj.Shape.fuse(tool_obj.Shape)
    elif b_type == "cut":
        shape = base_obj.Shape.cut(tool_obj.Shape)
    elif b_type == "common":
        shape = base_obj.Shape.common(tool_obj.Shape)
    else:
        raise ValueError(f"Unknown boolean type: {b_type}")

    obj = doc.addObject("Part::Feature", name)
    obj.Label = name
    obj.Shape = shape

    # Clean up the original objects by making them invisible if in GI, or simply leaving them in headless
    # In pure python headless, FreeCAD hides this but we can delete them if strict cleanup is requested.
    # We will leave them for now.


path: scripts/ops/export.py
content: import FreeCAD as App
import Part
import Mesh

def run(p, doc=None):
    if doc is None:
        doc = App.ActiveDocument

    import os
    format = p.get("format", "stl").lower()
    raw_filename = p.get("filename", f"model.{format}")
    filename = os.path.basename(raw_filename)  # Prevent path traversal
    
    target_name = p.get("target")
    
    export_objs = []
    if target_name:
        from . import utils
        obj = utils.find_object(doc, target_name)
        if obj and hasattr(obj, "Shape") and obj.Shape and not obj.Shape.isNull():
            export_objs.append(obj)
    else:
        for obj in doc.Objects:
            if hasattr(obj, "Shape") and obj.Shape and not obj.Shape.isNull():
                export_objs.append(obj)
                
    if not export_objs:
        print("No objects found to export.")
        return

    if format == "step":
        Part.export(export_objs, filename)
    elif format == "stl":
        Mesh.export(export_objs, filename)
    elif format == "brep":
        Part.export(export_objs, filename)
    else:
        raise ValueError(f"Unsupported export format: {format}. Supported: step, stl, brep")


path: scripts/ops/inspect.py
content: import FreeCAD as App

# Inspect doesn't need to do anything to the document since cad_engine always prints 
# document state at the end of execution. Calling this operation is just a fast way 
# to trigger a state read.
def run(p, doc=None):
    pass


path: scripts/ops/modifier.py
content: import FreeCAD as App
from . import utils

def run(p, doc=None):
    if doc is None:
        doc = App.ActiveDocument

    m_type = p.get("type", "fillet").lower()
    target_name = p.get("target")
    name = p.get("name", f"{m_type}_{target_name}")
    radius = p.get("radius", p.get("distance", 1.0))
    indices = p.get("indices")  

    obj = utils.find_object(doc, target_name)
    if not obj or not hasattr(obj, "Shape"):
        raise ValueError(f"Target object '{target_name}' not found or has no solid shape")

    shape = obj.Shape
    edges = shape.Edges

    if not edges:
        raise ValueError(f"Target object '{target_name}' has no edges to modify")

    # If indices not provided, apply to all edges
    edge_list = indices if indices else list(range(1, len(edges) + 1))

    new_shape = None
    if m_type == "fillet":
        new_shape = shape.makeFillet(radius, edge_list)
    elif m_type == "chamfer":
        new_shape = shape.makeChamfer(radius, edge_list)
    else:
        raise ValueError(f"Unknown modifier type: {m_type}")

    new_obj = doc.addObject("Part::Feature", name)
    new_obj.Label = name
    new_obj.Shape = new_shape
    return new_obj


path: scripts/ops/primitive.py
content: import FreeCAD as App
import Part
from . import utils

def run(p, doc=None):
    if doc is None:
        doc = App.ActiveDocument

    p_type = p.get("type", "box").lower()
    name = p.get("name", p_type.capitalize())
    
    shape = None
    if p_type == "box":
        shape = Part.makeBox(p.get("length", 10), p.get("width", 10), p.get("height", 10))
    elif p_type == "cylinder":
        shape = Part.makeCylinder(p.get("radius", 5), p.get("height", 10))
    elif p_type == "sphere":
        shape = Part.makeSphere(p.get("radius", 5))
    elif p_type == "cone":
        shape = Part.makeCone(p.get("radius1", 5), p.get("radius2", 0), p.get("height", 10))
    elif p_type == "torus":
        shape = Part.makeTorus(p.get("radius1", 10), p.get("radius2", 2))
    else:
        raise ValueError(f"Unknown primitive type: {p_type}")

    obj = doc.addObject("Part::Feature", name)
    obj.Label = name
    obj.Shape = shape
    
    # Optional base placement
    pos = p.get("position")
    if pos:
        obj.Placement.Base = App.Vector(*pos)


path: scripts/ops/profile.py
content: import FreeCAD as App
import Part
from . import utils

def run(p, doc=None):
    if doc is None:
        doc = App.ActiveDocument

    p_type = p.get("type", "extrude").lower()
    name = p.get("name", p_type.capitalize())
    points = p.get("points", [])
    
    if p_type in ["extrude", "revolve"]:
        if len(points) < 3:
            raise ValueError("Profile needs at least 3 points [x, y, z]")
        
        vecs = [App.Vector(pt[0], pt[1], pt.get(2, 0) if len(pt)>2 else 0) for pt in points]
        if vecs[0] != vecs[-1]:
            vecs.append(vecs[0])
            
        polygon = Part.makePolygon(vecs)
        face = Part.Face(polygon)
        
        if face.isNull() or not face.isValid():
             raise ValueError("Generated profile face is invalid. Points might not be coplanar.")

        shape = None
        if p_type == "extrude":
            dir_vec = App.Vector(*p.get("direction", [0, 0, 10]))
            shape = face.extrude(dir_vec)
        elif p_type == "revolve":
            axis = App.Vector(*p.get("axis", [0, 1, 0]))
            center = App.Vector(*p.get("center", [0, 0, 0]))
            angle = p.get("angle", 360)
            shape = face.revolve(center, axis, angle)

        obj = doc.addObject("Part::Feature", name)
        obj.Label = name
        obj.Shape = shape
        return obj
    else:
        raise ValueError(f"Unknown profile type: {p_type}")


path: scripts/ops/transform.py
content: import FreeCAD as App
from . import utils

def run(p, doc=None):
    if doc is None:
        doc = App.ActiveDocument

    target_name = p.get("target")
    if not target_name:
        raise ValueError("Transform operation requires a 'target' object name")

    obj = utils.find_object(doc, target_name)
    if not obj:
        raise ValueError(f"Could not find target object: {target_name}")

    # Translation
    translate = p.get("translate")
    if translate:
        vec = App.Vector(*translate)
        obj.Placement.Base = obj.Placement.Base.add(vec)

    # Rotation (Roll, Pitch, Yaw in degrees)
    rotate = p.get("rotate")
    if rotate:
        rot = App.Rotation(*rotate)
        obj.Placement.Rotation = obj.Placement.Rotation.multiply(rot)

    # Scale uniformly or non-uniformly
    scale = p.get("scale")
    if scale is not None:
        if isinstance(scale, (int, float)):
            # Uniform
            obj.Shape = obj.Shape.scale(float(scale))
        elif isinstance(scale, list) and len(scale) == 3:
            # Non-uniform scaling requires matrix operation
            mat = App.Matrix()
            mat.scale(scale[0], scale[1], scale[2])
            obj.Shape = obj.Shape.transformShape(mat)


path: scripts/ops/utils.py
content: import FreeCAD as App
import Part
import os

def load_or_create_doc(filename="model.FCStd", doc_name="Model"):
    """Loads an existing FreeCAD document or creates a new one."""
    safe_filename = os.path.basename(filename)
    if os.path.exists(safe_filename):
        doc = App.openDocument(safe_filename)
    else:
        doc = App.newDocument(doc_name)
    App.setActiveDocument(doc.Name)
    return doc

def find_object(doc, name):
    """Finds an object by name or label in the document."""
    obj = doc.getObject(name)
    if not obj:
        obj = doc.getObject(name.replace(" ", "_"))
    if not obj:
        # Search by Label
        for o in doc.Objects:
            if o.Label == name:
                return o
    return obj

def save_and_export(doc, fcstd_filename="model.FCStd", step_filename="model.step"):
    """Saves the document and exports all Part features to a STEP file."""
    safe_fcstd = os.path.basename(fcstd_filename)
    safe_step = os.path.basename(step_filename)
    
    doc.recompute()
    doc.saveAs(safe_fcstd)
    
    export_objs = []
    for obj in doc.Objects:
        if hasattr(obj, "Shape") and obj.Shape and not obj.Shape.isNull():
            export_objs.append(obj)
            
    if export_objs:
        Part.export(export_objs, safe_step)

def get_document_state(doc):
    """Returns a dictionary of all relevant shapes and their bounding boxes for the LLM context."""
    objs = []
    for obj in doc.Objects:
        if hasattr(obj, "Shape") and obj.Shape and not obj.Shape.isNull():
            bb = obj.Shape.BoundBox
            objs.append({
                "name": obj.Name,
                "label": obj.Label,
                "volume": obj.Shape.Volume if hasattr(obj.Shape, "Volume") else 0,
                "bounding_box": {
                    "x_min": round(bb.XMin, 3), "x_max": round(bb.XMax, 3),
                    "y_min": round(bb.YMin, 3), "y_max": round(bb.YMax, 3),
                    "z_min": round(bb.ZMin, 3), "z_max": round(bb.ZMax, 3)
                }
            })
    return objs


path: skill.json
content: name: freecad
description: Parametric CAD engine for FreeCAD using structured JSON operations
version: 1.0.0
entry_point: python scripts/cad_engine.py

Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)