Parametric dovetail camera quick-release plate, spreadsheet-driven Codex

Codex

I’m going to use the qwen-mm-plugins-freecad skill and keep the actual modeling/export/verification inside its MCP tool path, as requested.

Codex

I have the FreeCAD MCP tools available now. I’m going to build this as a real FreeCAD parametric feature whose driving properties are expression-linked to Spreadsheet::Sheet aliases, then use spreadsheet edits plus recompute for the requested variation.

Codex

The model will use a trapezoidal dovetail prism along the 100 mm length, boolean cuts for the two through-slots and the screw/counterbore, then fused raised slot lips and a rear stop. I’ll keep the editable dimensions in Sheet aliases and drive the feature properties with FreeCAD expressions such as Sheet.DovetailTopWidth, so a spreadsheet edit is what changes the geometry.

list_documents{}
{}
Wall time: 16.3914 seconds
Output:
[{"type":"text","text":"[\n  \"Codex_Dovetail_QuickRelease\"\n]"}]
Codex

I’m about to create the FreeCAD document, spreadsheet aliases, expression-driven feature, and export pipeline in one FreeCAD-side script. After the first export, the same script will edit only the spreadsheet cells, recompute, validate the resulting solid, collect mass properties, and overwrite the exports with the changed model.

execute_code{"code": "import os, math, traceback\nimport FreeCAD as App\nimport FreeCADGui as Gui\nimport Part\n
{
  "code": "import os, math, traceback\nimport FreeCAD as App\nimport FreeCADGui as Gui\nimport Part\n\ntry:\n    import Import\nexcept Exception:\n    Import = None\ntry:\n    import Mesh\nexcept Exception:\n    Mesh = None\n\nDOC_NAME = \"Codex_Dovetail_QuickRelease\"\nASSET_DIR = \"/path/to/assets\"\nBASE = \"freecad-codex-dovetail-quick-release\"\nfcstd_path = os.path.join(ASSET_DIR, BASE + \".FCStd\")\nstep_path = os.path.join(ASSET_DIR, BASE + \".step\")\nstl_path = os.path.join(ASSET_DIR, BASE + \".stl\")\npng_path = os.path.join(ASSET_DIR, BASE + \".png\")\n\nos.makedirs(ASSET_DIR, exist_ok=True)\n\n# Reset this named document only.\nif App.getDocument(DOC_NAME):\n    App.closeDocument(DOC_NAME)\ndoc = App.newDocument(DOC_NAME)\nGui.ActiveDocument = Gui.getDocument(doc.Name)\n\nclass DovetailQuickReleaseProxy:\n    def __init__(self, obj):\n        obj.Proxy = self\n\n    def __getstate__(self):\n        return {}\n\n    def __setstate__(self, state):\n        return None\n\n    @staticmethod\n    def _mm(value):\n        try:\n            return float(value.Value)\n        except Exception:\n            return float(value)\n\n    def _trapezoid_prism(self, L, Wb, Wt, H):\n        x0, x1 = -L / 2.0, L / 2.0\n        yb, yt = Wb / 2.0, Wt / 2.0\n        pts = [\n            App.Vector(x0, -yb, 0), App.Vector(x1, -yb, 0), App.Vector(x1, yb, 0), App.Vector(x0, yb, 0),\n            App.Vector(x0, -yt, H), App.Vector(x1, -yt, H), App.Vector(x1, yt, H), App.Vector(x0, yt, H),\n        ]\n        faces_idx = [\n            (0, 1, 2, 3),  # bottom\n            (4, 7, 6, 5),  # top\n            (0, 4, 5, 1),  # left rail face\n            (1, 5, 6, 2),  # front end\n            (2, 6, 7, 3),  # right rail face\n            (3, 7, 4, 0),  # rear end\n        ]\n        faces = []\n        for idx in faces_idx:\n            wire = Part.makePolygon([pts[i] for i in idx] + [pts[idx[0]]])\n            faces.append(Part.Face(wire))\n        shell = Part.makeShell(faces)\n        solid = Part.makeSolid(shell)\n        solid = solid.removeSplitter()\n        if not solid.isValid():\n            solid = solid.fix(0.01, 0.01, 0.01)\n        return solid\n\n    def _capsule_solid(self, cx, cy, z, length, width, height):\n        r = width / 2.0\n        straight = max(length - width, 0.1)\n        box = Part.makeBox(straight, width, height, App.Vector(cx - straight / 2.0, cy - r, z))\n        cyl1 = Part.makeCylinder(r, height, App.Vector(cx - straight / 2.0, cy, z), App.Vector(0, 0, 1))\n        cyl2 = Part.makeCylinder(r, height, App.Vector(cx + straight / 2.0, cy, z), App.Vector(0, 0, 1))\n        return box.fuse(cyl1).fuse(cyl2).removeSplitter()\n\n    def execute(self, obj):\n        L = self._mm(obj.PlateLength)\n        Wb = self._mm(obj.BottomWidth)\n        Wt = self._mm(obj.DovetailTopWidth)\n        H = self._mm(obj.PlateThickness)\n        slot_l = self._mm(obj.SlotLength)\n        slot_w = self._mm(obj.SlotWidth)\n        slot_spacing = self._mm(obj.SlotSpacing)\n        lip_margin = self._mm(obj.LipMargin)\n        lip_height = self._mm(obj.LipHeight)\n        clearance_d = self._mm(obj.ClearanceDiameter)\n        cb_d = self._mm(obj.CounterboreDiameter)\n        cb_depth = self._mm(obj.CounterboreDepth)\n        stop_l = self._mm(obj.StopLength)\n        stop_h = self._mm(obj.StopHeight)\n\n        # Conservative clamps keep spreadsheet edits from producing inverted rail geometry.\n        Wt = min(max(Wt, 8.0), Wb - 2.0)\n        slot_w = min(max(slot_w, 2.0), max(2.0, Wt - 8.0))\n        stop_l = min(max(stop_l, 2.0), L / 3.0)\n\n        plate = self._trapezoid_prism(L, Wb, Wt, H)\n\n        # Through elongated mounting slots, centered on the top rail and separated along X.\n        for cx in (-slot_spacing / 2.0, slot_spacing / 2.0):\n            slot_cut = self._capsule_solid(cx, 0.0, -1.0, slot_l, slot_w, H + lip_height + stop_h + 3.0)\n            plate = plate.cut(slot_cut).removeSplitter()\n\n        # Centered 1/4-20 clearance hole plus top counterbore.\n        through = Part.makeCylinder(clearance_d / 2.0, H + lip_height + stop_h + 3.0, App.Vector(0, 0, -1.0), App.Vector(0, 0, 1))\n        c_bore = Part.makeCylinder(cb_d / 2.0, cb_depth + 1.0, App.Vector(0, 0, H - cb_depth), App.Vector(0, 0, 1))\n        plate = plate.cut(through).cut(c_bore).removeSplitter()\n\n        # Raised lips around the two slots, modeled as capsule rings on the top face.\n        lips = []\n        for cx in (-slot_spacing / 2.0, slot_spacing / 2.0):\n            outer = self._capsule_solid(cx, 0.0, H, slot_l + 2.0 * lip_margin, slot_w + 2.0 * lip_margin, lip_height)\n            inner = self._capsule_solid(cx, 0.0, H - 0.2, slot_l, slot_w, lip_height + 0.4)\n            lip = outer.cut(inner).removeSplitter()\n            lips.append(lip)\n        if lips:\n            plate = plate.fuse(lips[0]).fuse(lips[1]).removeSplitter()\n\n        # Rear stop block across the top rail at the positive-X end.\n        stop = Part.makeBox(stop_l, Wt, stop_h, App.Vector(L / 2.0 - stop_l, -Wt / 2.0, H))\n        plate = plate.fuse(stop).removeSplitter()\n        plate = plate.removeSplitter()\n        if not plate.isValid():\n            fixed = plate.fix(0.01, 0.01, 0.01)\n            if fixed and fixed.isValid():\n                plate = fixed\n        obj.Shape = plate\n\n# Spreadsheet with user-editable driving dimensions.\nsheet = doc.addObject(\"Spreadsheet::Sheet\", \"Sheet\")\nrows = [\n    (\"PlateLength\", \"100 mm\", \"overall length\"),\n    (\"BottomWidth\", \"42 mm\", \"dovetail lower width\"),\n    (\"DovetailTopWidth\", \"34 mm\", \"dovetail top width\"),\n    (\"PlateThickness\", \"10 mm\", \"plate thickness\"),\n    (\"SlotLength\", \"24 mm\", \"elongated slot length\"),\n    (\"SlotWidth\", \"7 mm\", \"elongated slot width\"),\n    (\"SlotSpacing\", \"54 mm\", \"slot center spacing\"),\n    (\"LipMargin\", \"3 mm\", \"raised lip margin around slots\"),\n    (\"LipHeight\", \"1.5 mm\", \"raised lip height\"),\n    (\"ClearanceDiameter\", \"6.8 mm\", \"1/4-20 clearance diameter\"),\n    (\"CounterboreDiameter\", \"13 mm\", \"counterbore diameter\"),\n    (\"CounterboreDepth\", \"3.2 mm\", \"counterbore depth\"),\n    (\"StopLength\", \"10 mm\", \"rear stop length\"),\n    (\"StopHeight\", \"8 mm\", \"rear stop height\"),\n]\nsheet.set(\"A1\", \"Parameter\")\nsheet.set(\"B1\", \"Value\")\nsheet.set(\"C1\", \"Note\")\nfor idx, (name, value, note) in enumerate(rows, start=2):\n    sheet.set(f\"A{idx}\", name)\n    sheet.set(f\"B{idx}\", value)\n    sheet.set(f\"C{idx}\", note)\n    sheet.setAlias(f\"B{idx}\", name)\n\nobj = doc.addObject(\"Part::FeaturePython\", \"DovetailQuickRelease\")\nobj.Label = \"Expression-driven dovetail quick-release plate\"\nDovetailQuickReleaseProxy(obj)\nfor prop in [\n    \"PlateLength\", \"BottomWidth\", \"DovetailTopWidth\", \"PlateThickness\", \"SlotLength\", \"SlotWidth\",\n    \"SlotSpacing\", \"LipMargin\", \"LipHeight\", \"ClearanceDiameter\", \"CounterboreDiameter\",\n    \"CounterboreDepth\", \"StopLength\", \"StopHeight\",\n]:\n    obj.addProperty(\"App::PropertyLength\", prop, \"Driving Dimensions\", f\"Expression-bound to Sheet.{prop}\")\n    obj.setExpression(prop, f\"Sheet.{prop}\")\n\n# Cosmetic display state.\nobj.ViewObject.ShapeColor = (0.72, 0.74, 0.70, 0.0)\nobj.ViewObject.LineColor = (0.12, 0.12, 0.12, 0.0)\nobj.ViewObject.DisplayMode = \"Shaded\"\ndoc.recompute()\n\n# Initial export before parameter edits.\nif Import:\n    Import.export([obj], step_path)\nif Mesh:\n    Mesh.export([obj], stl_path)\ndoc.saveAs(fcstd_path)\nGui.ActiveDocument.ActiveView.viewIsometric()\nGui.ActiveDocument.ActiveView.fitAll()\nGui.ActiveDocument.ActiveView.saveImage(png_path, 1400, 1000, \"White\")\ninitial = {\n    \"bbox\": tuple(round(v, 4) for v in (obj.Shape.BoundBox.XLength, obj.Shape.BoundBox.YLength, obj.Shape.BoundBox.ZLength)),\n    \"volume\": round(obj.Shape.Volume, 4),\n    \"valid\": obj.Shape.isValid(),\n    \"shape_type\": obj.Shape.ShapeType,\n    \"solid_count\": len(obj.Shape.Solids),\n}\n\n# Requested parametric edit: modify only spreadsheet cells, then recompute.\nsheet.set(\"B4\", \"31 mm\")      # DovetailTopWidth\nsheet.set(\"B7\", \"8.2 mm\")     # SlotWidth\nsheet.set(\"B15\", \"12 mm\")     # StopHeight\ndoc.recompute()\n\n# Final validity and export after edit.\nshape = obj.Shape\nbbox = shape.BoundBox\nfinal = {\n    \"bbox_min\": (round(bbox.XMin, 4), round(bbox.YMin, 4), round(bbox.ZMin, 4)),\n    \"bbox_max\": (round(bbox.XMax, 4), round(bbox.YMax, 4), round(bbox.ZMax, 4)),\n    \"bbox_size\": (round(bbox.XLength, 4), round(bbox.YLength, 4), round(bbox.ZLength, 4)),\n    \"volume_mm3\": round(shape.Volume, 4),\n    \"valid\": bool(shape.isValid()),\n    \"shape_type\": shape.ShapeType,\n    \"solid_count\": len(shape.Solids),\n    \"edited_cells\": {\n        \"Sheet.DovetailTopWidth\": sheet.get(\"B4\"),\n        \"Sheet.SlotWidth\": sheet.get(\"B7\"),\n        \"Sheet.StopHeight\": sheet.get(\"B15\"),\n    },\n}\nif shape.ShapeType != \"Solid\" or len(shape.Solids) != 1 or not shape.isValid():\n    raise RuntimeError(\"Final model is not a single valid solid: %r\" % final)\n\nif Import:\n    Import.export([obj], step_path)\nelse:\n    raise RuntimeError(\"FreeCAD Import module unavailable; STEP export skipped\")\nif Mesh:\n    Mesh.export([obj], stl_path)\nelse:\n    raise RuntimeError(\"FreeCAD Mesh module unavailable; STL export skipped\")\n\ndoc.saveAs(fcstd_path)\nGui.ActiveDocument.ActiveView.viewIsometric()\nGui.ActiveDocument.ActiveView.fitAll()\nGui.ActiveDocument.ActiveView.saveImage(png_path, 1400, 1000, \"White\")\n\nprint(\"Created expression-driven spreadsheet-parametric quick-release plate\")\nprint(\"Initial export stats:\", initial)\nprint(\"Final edited-cell stats:\", final)\nprint(\"Exports:\")\nprint(\"  FCStd:\", fcstd_path)\nprint(\"  STEP:\", step_path)\nprint(\"  STL:\", stl_path)\nprint(\"  PNG:\", png_path)\n",
  "timeout_ms": 120000
}
Wall time: 17.9070 seconds
Output:
[{"type":"text","text":"Failed to execute code: ValueError: 'Shaded' is not part of the enumeration in Codex_Dovetail_QuickRelease#DovetailQuickRelease.ViewObject.DisplayMode"}]
Codex

FreeCAD rejected one cosmetic view setting (DisplayMode = "Shaded") before the model reached export. I’m rerunning the same FreeCAD-side build without that nonessential view property.

execute_code{"code":"import os, math, traceback\nimport FreeCAD as App\nimport FreeCADGui as Gui\nimport Part\n\
{
  "code": "import os, math, traceback\nimport FreeCAD as App\nimport FreeCADGui as Gui\nimport Part\n\ntry:\n    import Import\nexcept Exception:\n    Import = None\ntry:\n    import Mesh\nexcept Exception:\n    Mesh = None\n\nDOC_NAME = \"Codex_Dovetail_QuickRelease\"\nASSET_DIR = \"/path/to/assets\"\nBASE = \"freecad-codex-dovetail-quick-release\"\nfcstd_path = os.path.join(ASSET_DIR, BASE + \".FCStd\")\nstep_path = os.path.join(ASSET_DIR, BASE + \".step\")\nstl_path = os.path.join(ASSET_DIR, BASE + \".stl\")\npng_path = os.path.join(ASSET_DIR, BASE + \".png\")\n\nos.makedirs(ASSET_DIR, exist_ok=True)\n\nif App.getDocument(DOC_NAME):\n    App.closeDocument(DOC_NAME)\ndoc = App.newDocument(DOC_NAME)\nGui.ActiveDocument = Gui.getDocument(doc.Name)\n\nclass DovetailQuickReleaseProxy:\n    def __init__(self, obj):\n        obj.Proxy = self\n    def __getstate__(self):\n        return {}\n    def __setstate__(self, state):\n        return None\n    @staticmethod\n    def _mm(value):\n        try:\n            return float(value.Value)\n        except Exception:\n            return float(value)\n    def _trapezoid_prism(self, L, Wb, Wt, H):\n        x0, x1 = -L / 2.0, L / 2.0\n        yb, yt = Wb / 2.0, Wt / 2.0\n        pts = [\n            App.Vector(x0, -yb, 0), App.Vector(x1, -yb, 0), App.Vector(x1, yb, 0), App.Vector(x0, yb, 0),\n            App.Vector(x0, -yt, H), App.Vector(x1, -yt, H), App.Vector(x1, yt, H), App.Vector(x0, yt, H),\n        ]\n        faces_idx = [(0,1,2,3),(4,7,6,5),(0,4,5,1),(1,5,6,2),(2,6,7,3),(3,7,4,0)]\n        faces = []\n        for idx in faces_idx:\n            wire = Part.makePolygon([pts[i] for i in idx] + [pts[idx[0]]])\n            faces.append(Part.Face(wire))\n        solid = Part.makeSolid(Part.makeShell(faces)).removeSplitter()\n        if not solid.isValid():\n            fixed = solid.fix(0.01, 0.01, 0.01)\n            if fixed:\n                solid = fixed\n        return solid\n    def _capsule_solid(self, cx, cy, z, length, width, height):\n        r = width / 2.0\n        straight = max(length - width, 0.1)\n        box = Part.makeBox(straight, width, height, App.Vector(cx - straight / 2.0, cy - r, z))\n        cyl1 = Part.makeCylinder(r, height, App.Vector(cx - straight / 2.0, cy, z), App.Vector(0, 0, 1))\n        cyl2 = Part.makeCylinder(r, height, App.Vector(cx + straight / 2.0, cy, z), App.Vector(0, 0, 1))\n        return box.fuse(cyl1).fuse(cyl2).removeSplitter()\n    def execute(self, obj):\n        L = self._mm(obj.PlateLength)\n        Wb = self._mm(obj.BottomWidth)\n        Wt = self._mm(obj.DovetailTopWidth)\n        H = self._mm(obj.PlateThickness)\n        slot_l = self._mm(obj.SlotLength)\n        slot_w = self._mm(obj.SlotWidth)\n        slot_spacing = self._mm(obj.SlotSpacing)\n        lip_margin = self._mm(obj.LipMargin)\n        lip_height = self._mm(obj.LipHeight)\n        clearance_d = self._mm(obj.ClearanceDiameter)\n        cb_d = self._mm(obj.CounterboreDiameter)\n        cb_depth = self._mm(obj.CounterboreDepth)\n        stop_l = self._mm(obj.StopLength)\n        stop_h = self._mm(obj.StopHeight)\n        Wt = min(max(Wt, 8.0), Wb - 2.0)\n        slot_w = min(max(slot_w, 2.0), max(2.0, Wt - 8.0))\n        stop_l = min(max(stop_l, 2.0), L / 3.0)\n        plate = self._trapezoid_prism(L, Wb, Wt, H)\n        for cx in (-slot_spacing / 2.0, slot_spacing / 2.0):\n            slot_cut = self._capsule_solid(cx, 0.0, -1.0, slot_l, slot_w, H + lip_height + stop_h + 3.0)\n            plate = plate.cut(slot_cut).removeSplitter()\n        through = Part.makeCylinder(clearance_d / 2.0, H + lip_height + stop_h + 3.0, App.Vector(0, 0, -1.0), App.Vector(0, 0, 1))\n        c_bore = Part.makeCylinder(cb_d / 2.0, cb_depth + 1.0, App.Vector(0, 0, H - cb_depth), App.Vector(0, 0, 1))\n        plate = plate.cut(through).cut(c_bore).removeSplitter()\n        for cx in (-slot_spacing / 2.0, slot_spacing / 2.0):\n            outer = self._capsule_solid(cx, 0.0, H, slot_l + 2.0 * lip_margin, slot_w + 2.0 * lip_margin, lip_height)\n            inner = self._capsule_solid(cx, 0.0, H - 0.2, slot_l, slot_w, lip_height + 0.4)\n            lip = outer.cut(inner).removeSplitter()\n            plate = plate.fuse(lip).removeSplitter()\n        stop = Part.makeBox(stop_l, Wt, stop_h, App.Vector(L / 2.0 - stop_l, -Wt / 2.0, H))\n        plate = plate.fuse(stop).removeSplitter()\n        if not plate.isValid():\n            fixed = plate.fix(0.01, 0.01, 0.01)\n            if fixed and fixed.isValid():\n                plate = fixed\n        obj.Shape = plate\n\nsheet = doc.addObject(\"Spreadsheet::Sheet\", \"Sheet\")\nrows = [\n    (\"PlateLength\", \"100 mm\", \"overall length\"),\n    (\"BottomWidth\", \"42 mm\", \"dovetail lower width\"),\n    (\"DovetailTopWidth\", \"34 mm\", \"dovetail top width\"),\n    (\"PlateThickness\", \"10 mm\", \"plate thickness\"),\n    (\"SlotLength\", \"24 mm\", \"elongated slot length\"),\n    (\"SlotWidth\", \"7 mm\", \"elongated slot width\"),\n    (\"SlotSpacing\", \"54 mm\", \"slot center spacing\"),\n    (\"LipMargin\", \"3 mm\", \"raised lip margin around slots\"),\n    (\"LipHeight\", \"1.5 mm\", \"raised lip height\"),\n    (\"ClearanceDiameter\", \"6.8 mm\", \"1/4-20 clearance diameter\"),\n    (\"CounterboreDiameter\", \"13 mm\", \"counterbore diameter\"),\n    (\"CounterboreDepth\", \"3.2 mm\", \"counterbore depth\"),\n    (\"StopLength\", \"10 mm\", \"rear stop length\"),\n    (\"StopHeight\", \"8 mm\", \"rear stop height\"),\n]\nsheet.set(\"A1\", \"Parameter\")\nsheet.set(\"B1\", \"Value\")\nsheet.set(\"C1\", \"Note\")\nfor idx, (name, value, note) in enumerate(rows, start=2):\n    sheet.set(f\"A{idx}\", name)\n    sheet.set(f\"B{idx}\", value)\n    sheet.set(f\"C{idx}\", note)\n    sheet.setAlias(f\"B{idx}\", name)\n\nobj = doc.addObject(\"Part::FeaturePython\", \"DovetailQuickRelease\")\nobj.Label = \"Expression-driven dovetail quick-release plate\"\nDovetailQuickReleaseProxy(obj)\nfor prop in [\"PlateLength\", \"BottomWidth\", \"DovetailTopWidth\", \"PlateThickness\", \"SlotLength\", \"SlotWidth\", \"SlotSpacing\", \"LipMargin\", \"LipHeight\", \"ClearanceDiameter\", \"CounterboreDiameter\", \"CounterboreDepth\", \"StopLength\", \"StopHeight\"]:\n    obj.addProperty(\"App::PropertyLength\", prop, \"Driving Dimensions\", f\"Expression-bound to Sheet.{prop}\")\n    obj.setExpression(prop, f\"Sheet.{prop}\")\nobj.ViewObject.ShapeColor = (0.72, 0.74, 0.70, 0.0)\nobj.ViewObject.LineColor = (0.12, 0.12, 0.12, 0.0)\n\ndoc.recompute()\nif Import:\n    Import.export([obj], step_path)\nelse:\n    raise RuntimeError(\"FreeCAD Import module unavailable; STEP export skipped\")\nif Mesh:\n    Mesh.export([obj], stl_path)\nelse:\n    raise RuntimeError(\"FreeCAD Mesh module unavailable; STL export skipped\")\ndoc.saveAs(fcstd_path)\nGui.ActiveDocument.ActiveView.viewIsometric()\nGui.ActiveDocument.ActiveView.fitAll()\nGui.ActiveDocument.ActiveView.saveImage(png_path, 1400, 1000, \"White\")\ninitial = {\"bbox_size\": tuple(round(v, 4) for v in (obj.Shape.BoundBox.XLength, obj.Shape.BoundBox.YLength, obj.Shape.BoundBox.ZLength)), \"volume_mm3\": round(obj.Shape.Volume, 4), \"valid\": obj.Shape.isValid(), \"shape_type\": obj.Shape.ShapeType, \"solid_count\": len(obj.Shape.Solids)}\n\nsheet.set(\"B4\", \"31 mm\")\nsheet.set(\"B7\", \"8.2 mm\")\nsheet.set(\"B15\", \"12 mm\")\ndoc.recompute()\nshape = obj.Shape\nbbox = shape.BoundBox\nfinal = {\"bbox_min\": (round(bbox.XMin, 4), round(bbox.YMin, 4), round(bbox.ZMin, 4)), \"bbox_max\": (round(bbox.XMax, 4), round(bbox.YMax, 4), round(bbox.ZMax, 4)), \"bbox_size\": (round(bbox.XLength, 4), round(bbox.YLength, 4), round(bbox.ZLength, 4)), \"volume_mm3\": round(shape.Volume, 4), \"valid\": bool(shape.isValid()), \"shape_type\": shape.ShapeType, \"solid_count\": len(shape.Solids), \"edited_cells\": {\"Sheet.DovetailTopWidth\": sheet.get(\"B4\"), \"Sheet.SlotWidth\": sheet.get(\"B7\"), \"Sheet.StopHeight\": sheet.get(\"B15\")}}\nif shape.ShapeType != \"Solid\" or len(shape.Solids) != 1 or not shape.isValid():\n    raise RuntimeError(\"Final model is not a single valid solid: %r\" % final)\nImport.export([obj], step_path)\nMesh.export([obj], stl_path)\ndoc.saveAs(fcstd_path)\nGui.ActiveDocument.ActiveView.viewIsometric()\nGui.ActiveDocument.ActiveView.fitAll()\nGui.ActiveDocument.ActiveView.saveImage(png_path, 1400, 1000, \"White\")\n\nprint(\"Created expression-driven spreadsheet-parametric quick-release plate\")\nprint(\"Initial export stats:\", initial)\nprint(\"Final edited-cell stats:\", final)\nprint(\"Exports:\")\nprint(\"  FCStd:\", fcstd_path)\nprint(\"  STEP:\", step_path)\nprint(\"  STL:\", stl_path)\nprint(\"  PNG:\", png_path)\n",
  "timeout_ms": 120000
}
Wall time: 13.3249 seconds
Output:
[{"type":"text","text":"Failed to execute code: RuntimeError: Final model is not a single valid solid: {'bbox_min': (-50.0, -21.0, 0.0), 'bbox_max': (50.0, 21.0, 22.0), 'bbox_size': (100.0, 42.0, 22.0), 'volume_mm3': 36481.5795, 'valid': True, 'shape_type': 'Compound', 'solid_count': 1, 'edited_cells': {'Sheet.DovetailTopWidth': 31.0 mm, 'Sheet.SlotWidth': 8.2 mm, 'Sheet.StopHeight': 12.0 mm}}"}]
Codex

The recomputed result is geometrically valid and contains one solid, but FreeCAD wrapped it as a Compound after the booleans. I’m tightening the feature so it normalizes any one-solid compound back to a Solid before validation/export.

execute_code{"code":"import os\nimport FreeCAD as App\nimport FreeCADGui as Gui\nimport Part\nimport Import\nimp
{
  "code": "import os\nimport FreeCAD as App\nimport FreeCADGui as Gui\nimport Part\nimport Import\nimport Mesh\n\nDOC_NAME = \"Codex_Dovetail_QuickRelease\"\nASSET_DIR = \"/path/to/assets\"\nBASE = \"freecad-codex-dovetail-quick-release\"\nfcstd_path = os.path.join(ASSET_DIR, BASE + \".FCStd\")\nstep_path = os.path.join(ASSET_DIR, BASE + \".step\")\nstl_path = os.path.join(ASSET_DIR, BASE + \".stl\")\npng_path = os.path.join(ASSET_DIR, BASE + \".png\")\nos.makedirs(ASSET_DIR, exist_ok=True)\nif App.getDocument(DOC_NAME):\n    App.closeDocument(DOC_NAME)\ndoc = App.newDocument(DOC_NAME)\nGui.ActiveDocument = Gui.getDocument(doc.Name)\n\nclass DovetailQuickReleaseProxy:\n    def __init__(self, obj):\n        obj.Proxy = self\n    def __getstate__(self):\n        return {}\n    def __setstate__(self, state):\n        return None\n    @staticmethod\n    def _mm(value):\n        try:\n            return float(value.Value)\n        except Exception:\n            return float(value)\n    @staticmethod\n    def _one_solid(shape):\n        shape = shape.removeSplitter()\n        if shape.ShapeType == \"Solid\":\n            return shape\n        solids = shape.Solids\n        if len(solids) == 1:\n            return solids[0].removeSplitter()\n        return shape\n    def _trapezoid_prism(self, L, Wb, Wt, H):\n        x0, x1 = -L/2.0, L/2.0\n        yb, yt = Wb/2.0, Wt/2.0\n        pts = [App.Vector(x0,-yb,0), App.Vector(x1,-yb,0), App.Vector(x1,yb,0), App.Vector(x0,yb,0), App.Vector(x0,-yt,H), App.Vector(x1,-yt,H), App.Vector(x1,yt,H), App.Vector(x0,yt,H)]\n        faces = []\n        for idx in [(0,1,2,3),(4,7,6,5),(0,4,5,1),(1,5,6,2),(2,6,7,3),(3,7,4,0)]:\n            faces.append(Part.Face(Part.makePolygon([pts[i] for i in idx] + [pts[idx[0]]])))\n        return self._one_solid(Part.makeSolid(Part.makeShell(faces)))\n    def _capsule_solid(self, cx, cy, z, length, width, height):\n        r = width/2.0\n        straight = max(length - width, 0.1)\n        box = Part.makeBox(straight, width, height, App.Vector(cx - straight/2.0, cy - r, z))\n        cyl1 = Part.makeCylinder(r, height, App.Vector(cx - straight/2.0, cy, z), App.Vector(0,0,1))\n        cyl2 = Part.makeCylinder(r, height, App.Vector(cx + straight/2.0, cy, z), App.Vector(0,0,1))\n        return self._one_solid(box.fuse(cyl1).fuse(cyl2))\n    def execute(self, obj):\n        L = self._mm(obj.PlateLength); Wb = self._mm(obj.BottomWidth); Wt = self._mm(obj.DovetailTopWidth); H = self._mm(obj.PlateThickness)\n        slot_l = self._mm(obj.SlotLength); slot_w = self._mm(obj.SlotWidth); slot_spacing = self._mm(obj.SlotSpacing)\n        lip_margin = self._mm(obj.LipMargin); lip_height = self._mm(obj.LipHeight)\n        clearance_d = self._mm(obj.ClearanceDiameter); cb_d = self._mm(obj.CounterboreDiameter); cb_depth = self._mm(obj.CounterboreDepth)\n        stop_l = self._mm(obj.StopLength); stop_h = self._mm(obj.StopHeight)\n        Wt = min(max(Wt, 8.0), Wb - 2.0)\n        slot_w = min(max(slot_w, 2.0), max(2.0, Wt - 8.0))\n        stop_l = min(max(stop_l, 2.0), L/3.0)\n        plate = self._trapezoid_prism(L, Wb, Wt, H)\n        for cx in (-slot_spacing/2.0, slot_spacing/2.0):\n            slot_cut = self._capsule_solid(cx, 0, -1, slot_l, slot_w, H + lip_height + stop_h + 3)\n            plate = self._one_solid(plate.cut(slot_cut))\n        through = Part.makeCylinder(clearance_d/2.0, H + lip_height + stop_h + 3, App.Vector(0,0,-1), App.Vector(0,0,1))\n        c_bore = Part.makeCylinder(cb_d/2.0, cb_depth + 1, App.Vector(0,0,H - cb_depth), App.Vector(0,0,1))\n        plate = self._one_solid(self._one_solid(plate.cut(through)).cut(c_bore))\n        for cx in (-slot_spacing/2.0, slot_spacing/2.0):\n            outer = self._capsule_solid(cx, 0, H, slot_l + 2*lip_margin, slot_w + 2*lip_margin, lip_height)\n            inner = self._capsule_solid(cx, 0, H - 0.2, slot_l, slot_w, lip_height + 0.4)\n            lip = self._one_solid(outer.cut(inner))\n            plate = self._one_solid(plate.fuse(lip))\n        stop = Part.makeBox(stop_l, Wt, stop_h, App.Vector(L/2.0 - stop_l, -Wt/2.0, H))\n        plate = self._one_solid(plate.fuse(stop))\n        if not plate.isValid():\n            fixed = plate.fix(0.01, 0.01, 0.01)\n            if fixed and fixed.isValid():\n                plate = self._one_solid(fixed)\n        obj.Shape = self._one_solid(plate)\n\nsheet = doc.addObject(\"Spreadsheet::Sheet\", \"Sheet\")\nrows = [(\"PlateLength\",\"100 mm\",\"overall length\"),(\"BottomWidth\",\"42 mm\",\"dovetail lower width\"),(\"DovetailTopWidth\",\"34 mm\",\"dovetail top width\"),(\"PlateThickness\",\"10 mm\",\"plate thickness\"),(\"SlotLength\",\"24 mm\",\"elongated slot length\"),(\"SlotWidth\",\"7 mm\",\"elongated slot width\"),(\"SlotSpacing\",\"54 mm\",\"slot center spacing\"),(\"LipMargin\",\"3 mm\",\"raised lip margin around slots\"),(\"LipHeight\",\"1.5 mm\",\"raised lip height\"),(\"ClearanceDiameter\",\"6.8 mm\",\"1/4-20 clearance diameter\"),(\"CounterboreDiameter\",\"13 mm\",\"counterbore diameter\"),(\"CounterboreDepth\",\"3.2 mm\",\"counterbore depth\"),(\"StopLength\",\"10 mm\",\"rear stop length\"),(\"StopHeight\",\"8 mm\",\"rear stop height\")]\nsheet.set(\"A1\", \"Parameter\"); sheet.set(\"B1\", \"Value\"); sheet.set(\"C1\", \"Note\")\nfor idx, (name, value, note) in enumerate(rows, start=2):\n    sheet.set(f\"A{idx}\", name); sheet.set(f\"B{idx}\", value); sheet.set(f\"C{idx}\", note); sheet.setAlias(f\"B{idx}\", name)\nobj = doc.addObject(\"Part::FeaturePython\", \"DovetailQuickRelease\")\nobj.Label = \"Expression-driven dovetail quick-release plate\"\nDovetailQuickReleaseProxy(obj)\nprops = [\"PlateLength\",\"BottomWidth\",\"DovetailTopWidth\",\"PlateThickness\",\"SlotLength\",\"SlotWidth\",\"SlotSpacing\",\"LipMargin\",\"LipHeight\",\"ClearanceDiameter\",\"CounterboreDiameter\",\"CounterboreDepth\",\"StopLength\",\"StopHeight\"]\nfor prop in props:\n    obj.addProperty(\"App::PropertyLength\", prop, \"Driving Dimensions\", f\"Expression-bound to Sheet.{prop}\")\n    obj.setExpression(prop, f\"Sheet.{prop}\")\nobj.ViewObject.ShapeColor = (0.72,0.74,0.70,0.0)\nobj.ViewObject.LineColor = (0.12,0.12,0.12,0.0)\ndoc.recompute()\nImport.export([obj], step_path); Mesh.export([obj], stl_path); doc.saveAs(fcstd_path)\nGui.ActiveDocument.ActiveView.viewIsometric(); Gui.ActiveDocument.ActiveView.fitAll(); Gui.ActiveDocument.ActiveView.saveImage(png_path, 1400, 1000, \"White\")\ninitial = {\"bbox_size\": tuple(round(v,4) for v in (obj.Shape.BoundBox.XLength,obj.Shape.BoundBox.YLength,obj.Shape.BoundBox.ZLength)), \"volume_mm3\": round(obj.Shape.Volume,4), \"valid\": obj.Shape.isValid(), \"shape_type\": obj.Shape.ShapeType, \"solid_count\": len(obj.Shape.Solids)}\nsheet.set(\"B4\", \"31 mm\"); sheet.set(\"B7\", \"8.2 mm\"); sheet.set(\"B15\", \"12 mm\")\ndoc.recompute()\nshape = obj.Shape\nbbox = shape.BoundBox\nfinal = {\"bbox_min\": (round(bbox.XMin,4),round(bbox.YMin,4),round(bbox.ZMin,4)), \"bbox_max\": (round(bbox.XMax,4),round(bbox.YMax,4),round(bbox.ZMax,4)), \"bbox_size\": (round(bbox.XLength,4),round(bbox.YLength,4),round(bbox.ZLength,4)), \"volume_mm3\": round(shape.Volume,4), \"valid\": bool(shape.isValid()), \"shape_type\": shape.ShapeType, \"solid_count\": len(shape.Solids), \"edited_cells\": {\"Sheet.DovetailTopWidth\": sheet.get(\"B4\"), \"Sheet.SlotWidth\": sheet.get(\"B7\"), \"Sheet.StopHeight\": sheet.get(\"B15\")}}\nif shape.ShapeType != \"Solid\" or len(shape.Solids) != 1 or not shape.isValid():\n    raise RuntimeError(\"Final model is not a single valid solid: %r\" % final)\nImport.export([obj], step_path); Mesh.export([obj], stl_path); doc.saveAs(fcstd_path)\nGui.ActiveDocument.ActiveView.viewIsometric(); Gui.ActiveDocument.ActiveView.fitAll(); Gui.ActiveDocument.ActiveView.saveImage(png_path, 1400, 1000, \"White\")\nprint(\"Created expression-driven spreadsheet-parametric quick-release plate\")\nprint(\"Initial export stats:\", initial)\nprint(\"Final edited-cell stats:\", final)\nprint(\"Exports:\")\nprint(\"  FCStd:\", fcstd_path)\nprint(\"  STEP:\", step_path)\nprint(\"  STL:\", stl_path)\nprint(\"  PNG:\", png_path)\n",
  "timeout_ms": 120000
}
Wall time: 8.1539 seconds
Output:
Code executed successfully: Python code executed successfully.
Output: Created expression-driven spreadsheet-parametric quick-release plate
Initial export stats: {'bbox_size': (100.0, 42.0, 18.0), 'volume_mm3': 37467.9283, 'valid': True, 'shape_type': 'Solid', 'solid_count': 1}
Final edited-cell stats: {'bbox_min': (-50.0, -21.0, 0.0), 'bbox_max': (50.0, 21.0, 22.0), 'bbox_size': (100.0, 42.0, 22.0), 'volume_mm3': 36481.5795, 'valid': True, 'shape_type': 'Solid', 'solid_count': 1, 'edited_cells': {'Sheet.DovetailTopWidth': 31.0 mm, 'Sheet.SlotWidth': 8.2 mm, 'Sheet.StopHeight': 12.0 mm}}
Exports:
  FCStd: /path/to/freecad-codex-dovetail-quick-release.FCStd
  STEP: /path/to/freecad-codex-dovetail-quick-release.step
  STL: /path/to/freecad-codex-dovetail-quick-release.stl
  PNG: /path/to/freecad-codex-dovetail-quick-release.png
Codex

The final recompute/export passed validation. I’m doing one FreeCAD view check now because the tool’s automatic screenshot came back as an empty viewport even though the saved files and shape stats were produced.

get_view{"view_name":"Isometric","focus_object":"DovetailQuickRelease","width":1200,"height":800}
{
  "view_name": "Isometric",
  "focus_object": "DovetailQuickRelease",
  "width": 1200,
  "height": 800
}
Wall time: 8.1610 seconds
Output:
Codex

The object is present and exported, but FreeCAD’s viewport capture is still blank. I’m checking the document object state and then I’ll regenerate the PNG using FreeCAD’s offscreen/image path inside the same MCP environment if the live viewport is not rendering the scene.

get_object{"doc_name":"Codex_Dovetail_QuickRelease","obj_name":"DovetailQuickRelease"}
{
  "doc_name": "Codex_Dovetail_QuickRelease",
  "obj_name": "DovetailQuickRelease"
}
Wall time: 6.6493 seconds
Output:
{
  "Name": "DovetailQuickRelease",
  "Label": "Expression-driven dovetail quick-release plate",
  "TypeId": "Part::FeaturePython",
  "Properties": {
    "BottomWidth": "42.0 mm",
    "ClearanceDiameter": "6.8 mm",
    "CounterboreDepth": "3.2 mm",
    "CounterboreDiameter": "13.0 mm",
    "DovetailTopWidth": "31.0 mm",
    "ExpressionEngine": [
      [
        "BottomWidth",
        "Sheet.BottomWidth"
      ],
      [
        "ClearanceDiameter",
        "Sheet.ClearanceDiameter"
      ],
      [
        "CounterboreDepth",
        "Sheet.CounterboreDepth"
      ],
      [
        "CounterboreDiameter",
        "Sheet.CounterboreDiameter"
      ],
      [
        "DovetailTopWidth",
        "Sheet.DovetailTopWidth"
      ],
      [
        "LipHeight",
        "Sheet.LipHeight"
      ],
      [
        "LipMargin",
        "Sheet.LipMargin"
      ],
      [
        "PlateLength",
        "Sheet.PlateLength"
      ],
      [
        "PlateThickness",
        "Sheet.PlateThickness"
      ],
      [
        "SlotLength",
        "Sheet.SlotLength"
      ],
      [
        "SlotSpacing",
        "Sheet.SlotSpacing"
      ],
      [
        "SlotWidth",
        "Sheet.SlotWidth"
      ],
      [
        "StopHeight",
        "Sheet.StopHeight"
      ],
      [
        "StopLength",
        "Sheet.StopLength"
      ]
    ],
    "Label": "Expression-driven dovetail quick-release plate",
    "Label2": "",
    "LipHeight": "1.5 mm",
    "LipMargin": "3.0 mm",
    "Placement": {
      "Base": {
        "x": 0.0,
        "y": 0.0,
        "z": 0.0
      },
      "Rotation": {
        "Axis": {
          "x": 0.0,
          "y": 0.0,
          "z": 1.0
        },
        "Angle": 0.0
      }
    },
    "PlateLength": "100.0 mm",
    "PlateThickness": "10.0 mm",
    "Proxy": "<rpc_server.rpc_server.DovetailQuickReleaseProxy object at 0x122c6dbd0>",
    "Shape": "<Solid object at 0xbc69a2800>",
    "ShapeMaterial": "<Material at 0xbcb41d500>",
    "SlotLength": "24.0 mm",
    "SlotSpacing": "54.0 mm",
    "SlotWidth": "8.2 mm",
    "StopHeight": "12.0 mm",
    "StopLength": "10.0 mm",
    "Visibility": true,
    "_ElementMapVersion": "15.70200.5"
  },
  "Placement": {
    "Base": {
      "x": 0.0,
      "y": 0.0,
      "z": 0.0
    },
    "Rotation": {
      "Axis": {
        "x": 0.0,
        "y": 0.0,
        "z": 1.0
      },
      "Angle": 0.0
    }
  },
  "Shape": {
    "Volume": 36481.57948296661,
    "Area": 12287.114161100935,
    "VertexCount": 56,
    "EdgeCount": 84,
    "FaceCount": 32
  },
  "ViewObject": {
    "ShapeColor": [
      0.7200000286102295,
      0.7400000095367432,
      0.699999988079071,
      0.0
    ],
    "Transparency": 0,
    "Visibility": true
  }
}
Codex

The GUI viewport renderer is not drawing into screenshots in this session, but the solid is available. I’m replacing the blank PNG with an isometric raster generated from the FreeCAD shape tessellation inside execute_code, including a pixel check so the saved PNG is not empty.

execute_code{"code":"import os, math\nimport FreeCAD as App\nfrom PIL import Image, ImageDraw\n\nDOC_NAME = \"Co
{
  "code": "import os, math\nimport FreeCAD as App\nfrom PIL import Image, ImageDraw\n\nDOC_NAME = \"Codex_Dovetail_QuickRelease\"\nASSET_DIR = \"/path/to/assets\"\npng_path = os.path.join(ASSET_DIR, \"freecad-codex-dovetail-quick-release.png\")\ndoc = App.getDocument(DOC_NAME)\nobj = doc.getObject(\"DovetailQuickRelease\")\nshape = obj.Shape\nverts, tris = shape.tessellate(0.45)\n\n# Isometric-style orthographic projection from final FreeCAD geometry.\nyaw = math.radians(-45.0)\npitch = math.radians(35.2643897)\ncy, sy = math.cos(yaw), math.sin(yaw)\ncp, sp = math.cos(pitch), math.sin(pitch)\n\ndef transform(v):\n    x, y, z = v.x, v.y, v.z\n    x1 = cy*x - sy*y\n    y1 = sy*x + cy*y\n    z1 = z\n    x2 = x1\n    y2 = cp*y1 - sp*z1\n    z2 = sp*y1 + cp*z1\n    return (x2, y2, z2)\n\nproj = [transform(v) for v in verts]\nxs = [p[0] for p in proj]; ys = [p[1] for p in proj]\nW, H = 1400, 1000\nmargin = 90\nscale = min((W - 2*margin)/(max(xs)-min(xs)), (H - 2*margin)/(max(ys)-min(ys)))\ncx = W/2.0 - scale*(min(xs)+max(xs))/2.0\ncy2 = H/2.0 + scale*(min(ys)+max(ys))/2.0\n\ndef screen(p):\n    return (cx + scale*p[0], cy2 - scale*p[1])\n\nlight = (0.35, -0.45, 0.82)\nllen = math.sqrt(sum(a*a for a in light)); light = tuple(a/llen for a in light)\ntri_records = []\nfor tri in tris:\n    p0, p1, p2 = [proj[i] for i in tri]\n    depth = (p0[2] + p1[2] + p2[2]) / 3.0\n    v0, v1, v2 = [verts[i] for i in tri]\n    ax, ay, az = v1.x-v0.x, v1.y-v0.y, v1.z-v0.z\n    bx, by, bz = v2.x-v0.x, v2.y-v0.y, v2.z-v0.z\n    nx, ny, nz = ay*bz-az*by, az*bx-ax*bz, ax*by-ay*bx\n    nlen = math.sqrt(nx*nx + ny*ny + nz*nz) or 1.0\n    nx, ny, nz = nx/nlen, ny/nlen, nz/nlen\n    shade = max(0.18, min(1.0, 0.55 + 0.45*(nx*light[0] + ny*light[1] + nz*light[2])))\n    base = (168, 174, 166)\n    color = tuple(max(0, min(255, int(c*shade))) for c in base)\n    tri_records.append((depth, [screen(p0), screen(p1), screen(p2)], color))\n\nimg = Image.new(\"RGB\", (W, H), \"white\")\ndraw = ImageDraw.Draw(img)\nfor depth, poly, color in sorted(tri_records, key=lambda r: r[0]):\n    draw.polygon(poly, fill=color)\nfor tri in tris:\n    pts = [screen(proj[i]) for i in tri]\n    draw.line([pts[0], pts[1], pts[2], pts[0]], fill=(70, 72, 70), width=1)\n\nimg.save(png_path)\nnonwhite = 0\npix = img.load()\nfor y in range(0, H, 5):\n    for x in range(0, W, 5):\n        if pix[x, y] != (255, 255, 255):\n            nonwhite += 1\n\nprint({\"png\": png_path, \"triangles\": len(tris), \"sampled_nonwhite_pixels\": nonwhite, \"bbox_size\": (round(shape.BoundBox.XLength, 4), round(shape.BoundBox.YLength, 4), round(shape.BoundBox.ZLength, 4)), \"volume_mm3\": round(shape.Volume, 4), \"valid\": shape.isValid(), \"shape_type\": shape.ShapeType, \"solid_count\": len(shape.Solids)})",
  "timeout_ms": 120000
}
Wall time: 14.2670 seconds
Output:
Code executed successfully: Python code executed successfully.
Output: {'png': '/path/to/freecad-codex-dovetail-quick-release.png', 'triangles': 680, 'sampled_nonwhite_pixels': 11979, 'bbox_size': (100.0, 42.0, 22.0), 'volume_mm3': 36481.5795, 'valid': True, 'shape_type': 'Solid', 'solid_count': 1}
Codex

Done. Created and re-exported:

The model has a Spreadsheet::Sheet with aliased driving cells, and the DovetailQuickRelease feature properties are expression-bound to those cells, e.g. Sheet.DovetailTopWidth, Sheet.SlotWidth, Sheet.StopHeight.

Edited spreadsheet cells and recomputed:

  • Sheet.DovetailTopWidth: 34 mm -> 31 mm
  • Sheet.SlotWidth: 7 mm -> 8.2 mm
  • Sheet.StopHeight: 8 mm -> 12 mm

Final verification after recompute:

  • Shape type: Solid
  • Solid count: 1
  • Valid: True
  • Bounding box min: (-50.0, -21.0, 0.0) mm
  • Bounding box max: (50.0, 21.0, 22.0) mm
  • Bounding box size: (100.0, 42.0, 22.0) mm
  • Volume: 36481.5795 mm^3

FreeCAD’s live viewport capture was blank in this session, so I generated the PNG from the final FreeCAD shape tessellation through the FreeCAD MCP execution path and verified it is non-empty.

Codex

I’ll fix the PNG from the existing FreeCAD document through the FreeCAD MCP tools: first I’ll inspect the actual DisplayMode enum, then hide/delete mesh artifacts if present, set an allowed solid display mode, and overwrite the PNG from the isometric view.

exec_command{"cmd":"sed -n '1,240p' /path/to/qwen-mm-plugins-fr
{
  "cmd": "sed -n '1,240p' /path/to/SKILL.md",
  "yield_time_ms": 10000,
  "max_output_tokens": 20000
}
Chunk ID: f787e9
Wall time: 0.0913 seconds
Process exited with code 0
Original token count: 1259
Output:
---
name: qwen-mm-plugins-freecad
description: Use whenever a task involves parametric CAD in FreeCAD — modeling parts and assemblies, editing object properties, technical drawings, importing/exporting STEP/STL/OBJ/DXF, PDF/Excel reports from a model, or finite-element (FEM/CalculiX) analysis. Requires a running FreeCAD instance with the FreeCADMCP addon (see Prerequisite).
---

You build parametric CAD content in FreeCAD by creating/editing document objects and, when needed, writing Python — against a **running** FreeCAD instance.

## Prerequisite: a live FreeCAD + addon (started for you on first use)

These tools are a **thin client**: they talk XML-RPC to a **running** FreeCAD carrying the FreeCADMCP
addon (bundled in this plugin), and do **not** launch FreeCAD while serving. Normally you start
nothing — just call a tool.

- **Default (plugin install):** `QWEN_MM_AUTOLAUNCH=1` is preset, so the **first tool call** brings
  FreeCAD up itself: it auto-installs the pinned FreeCAD 1.1.x if missing (Linux-x86_64, rootless,
  FUSE-free AppImage, ~1 GB one-time), copies the bundled addon into your FreeCAD Mod dir, and starts
  it on `$FREECAD_RPC_HOST:$FREECAD_RPC_PORT` (default `localhost:9875`). Just call a tool such as
  `get_objects`; the **first** call may take a couple of minutes while it downloads (expected), later
  calls are instant.
- **Don't shell out to `qwen-mm-plugins-freecad --launch-app` under a plugin install** — that console
  entry lives inside the uvx environment, not your shell PATH (`command not found`). Use it only from
  a source checkout, or for a manual / GUI start:
  ```bash
  python3 src/capabilities/freecad/qwen_mm_plugins_freecad --launch-app        # headless (xvfb)
  python3 src/capabilities/freecad/qwen_mm_plugins_freecad --launch-app --gui  # real display
  ```

Auto-launch can't cover a few things: (1) auto-download is **Linux-x86_64 only** — elsewhere install
FreeCAD yourself (`apt install freecad`, or extract an AppImage and set `FREECAD_BINARY=<AppRun>`);
(2) a headless box needs a virtual display (`apt install xvfb`, needs root); (3) FEM needs the
CalculiX solver (`ccx`) on PATH. If a tool reports it can't connect, it's almost always one of the
first two — the error message spells out which; from a checkout, `... --check-system` lists every
missing system tool. Set `FREECAD_ONLY_TEXT_FEEDBACK=1` to drop the screenshot most tools attach.

## Asset creation strategy

When creating content in FreeCAD, follow these steps:

0. Before starting any task, always use `get_objects` to confirm the current state of the document (and `list_documents` / `create_document` as needed).
1. **Utilize the parts library**: check available parts with `get_parts_list`; if the required part exists, use `insert_part_from_library` to insert it.
2. **If the part isn't in the library**: create basic shapes (`Part::Box`, `Part::Cylinder`, `Part::Sphere`, `Draft::*`, `PartDesign::*`, …) with `create_object`, then refine detailed properties with `edit_object`.
3. Always assign clear, descriptive names to objects.
4. Explicitly set position, scale, and rotation (Placement) via `create_object`/`edit_object` to ensure correct spatial relationships.
5. After editing an object, **verify** the properties actually applied using `get_object`.
6. For detailed customization or specialized operations, use `execute_code` to run custom Python.

Only fall back to basic creation methods when: the asset isn't in the parts library, a basic shape is explicitly requested, or a complex shape requires custom scripting.

## execute_code vs execute_code_async

- **`execute_code`** runs on FreeCAD's GUI thread — the safe default for anything that touches documents, document objects, `FreeCADGui`, the active view, selection, `recompute()`, or save.
- **`execute_code_async`** runs in a background thread — ONLY for long, pure OCCT/CPU computations that do NOT touch the GUI or the document tree. Pattern: fetch shapes with `execute_code`, stash intermediates in a module-level Python variable, run the heavy compute async, then apply results back via `execute_code`.

## Visual verification

Use `get_view` (Isometric/Front/Top/…) to inspect geometry, and re-check with `get_object` after edits. Most mutating tools already return a screenshot unless `FREECAD_ONLY_TEXT_FEEDBACK` is set.

## FEM (finite-element) analysis

`run_fem_analysis` runs the CalculiX solver on a `Fem::AnalysisPython` container. Prerequisites in the document: a solid geometry, a `Fem::MaterialCommon`, a `Fem::FemMeshGmsh` referencing the geometry, and at least one `Fem::ConstraintFixed` + one `Fem::ConstraintForce`/`ConstraintPressure` — all added to the analysis (create them via `create_object`). CalculiX (`ccx`) must be installed. Returns max von Mises stress, max/min displacement, and node count.

## Output files

Save the final `.FCStd` and any generated outputs (images, PDFs, Excel, STEP/STL/OBJ/DXF exports) to the `exports/` directory unless the task specifies otherwise.
get_objects{"doc_name":"Codex_Dovetail_QuickRelease"}
{
  "doc_name": "Codex_Dovetail_QuickRelease"
}
Wall time: 16.3640 seconds
Output:
[
  {
    "Name": "Sheet",
    "Label": "Sheet",
    "TypeId": "Spreadsheet::Sheet",
    "Properties": {
      "A1": "Parameter",
      "A10": "LipHeight",
      "A11": "ClearanceDiameter",
      "A12": "CounterboreDiameter",
      "A13": "CounterboreDepth",
      "A14": "StopLength",
      "A15": "StopHeight",
      "A2": "PlateLength",
      "A3": "BottomWidth",
      "A4": "DovetailTopWidth",
      "A5": "PlateThickness",
      "A6": "SlotLength",
      "A7": "SlotWidth",
      "A8": "SlotSpacing",
      "A9": "LipMargin",
      "B1": "Value",
      "B10": "1.5 mm",
      "B11": "6.8 mm",
      "B12": "13.0 mm",
      "B13": "3.2 mm",
      "B14": "10.0 mm",
      "B15": "12.0 mm",
      "B2": "100.0 mm",
      "B3": "42.0 mm",
      "B4": "31.0 mm",
      "B5": "10.0 mm",
      "B6": "24.0 mm",
      "B7": "8.2 mm",
      "B8": "54.0 mm",
      "B9": "3.0 mm",
      "C1": "Note",
      "C10": "raised lip height",
      "C11": "1/4-20 clearance diameter",
      "C12": "counterbore diameter",
      "C13": "counterbore depth",
      "C14": "rear stop length",
      "C15": "rear stop height",
      "C2": "overall length",
      "C3": "dovetail lower width",
      "C4": "dovetail top width",
      "C5": "plate thickness",
      "C6": "elongated slot length",
      "C7": "elongated slot width",
      "C8": "slot center spacing",
      "C9": "raised lip margin around slots",
      "ExpressionEngine": [],
      "Label": "Sheet",
      "Label2": "",
      "Visibility": true,
      "cells": "<PropertySheet object>",
      "columnWidths": "<PropertyColumnWidths object>",
      "rowHeights": "<PropertyRowHeights object>"
    },
    "Placement": "None",
    "Shape": null,
    "ViewObject": {
      "Visibility": true
    }
  },
  {
    "Name": "DovetailQuickRelease",
    "Label": "Expression-driven dovetail quick-release plate",
    "TypeId": "Part::FeaturePython",
    "Properties": {
      "BottomWidth": "42.0 mm",
      "ClearanceDiameter": "6.8 mm",
      "CounterboreDepth": "3.2 mm",
      "CounterboreDiameter": "13.0 mm",
      "DovetailTopWidth": "31.0 mm",
      "ExpressionEngine": [
        [
          "BottomWidth",
          "Sheet.BottomWidth"
        ],
        [
          "ClearanceDiameter",
          "Sheet.ClearanceDiameter"
        ],
        [
          "CounterboreDepth",
          "Sheet.CounterboreDepth"
        ],
        [
          "CounterboreDiameter",
          "Sheet.CounterboreDiameter"
        ],
        [
          "DovetailTopWidth",
          "Sheet.DovetailTopWidth"
        ],
        [
          "LipHeight",
          "Sheet.LipHeight"
        ],
        [
          "LipMargin",
          "Sheet.LipMargin"
        ],
        [
          "PlateLength",
          "Sheet.PlateLength"
        ],
        [
          "PlateThickness",
          "Sheet.PlateThickness"
        ],
        [
          "SlotLength",
          "Sheet.SlotLength"
        ],
        [
          "SlotSpacing",
          "Sheet.SlotSpacing"
        ],
        [
          "SlotWidth",
          "Sheet.SlotWidth"
        ],
        [
          "StopHeight",
          "Sheet.StopHeight"
        ],
        [
          "StopLength",
          "Sheet.StopLength"
        ]
      ],
      "Label": "Expression-driven dovetail quick-release plate",
      "Label2": "",
      "LipHeight": "1.5 mm",
      "LipMargin": "3.0 mm",
      "Placement": {
        "Base": {
          "x": 0.0,
          "y": 0.0,
          "z": 0.0
        },
        "Rotation": {
          "Axis": {
            "x": 0.0,
            "y": 0.0,
            "z": 1.0
          },
          "Angle": 0.0
        }
      },
      "PlateLength": "100.0 mm",
      "PlateThickness": "10.0 mm",
      "Proxy": "<rpc_server.rpc_server.DovetailQuickReleaseProxy object at 0x122c6dbd0>",
      "Shape": "<Solid object at 0xbc6bedf40>",
      "ShapeMaterial": "<Material at 0xbcb41f900>",
      "SlotLength": "24.0 mm",
      "SlotSpacing": "54.0 mm",
      "SlotWidth": "8.2 mm",
      "StopHeight": "12.0 mm",
      "StopLength": "10.0 mm",
      "Visibility": true,
      "_ElementMapVersion": "15.70200.5"
    },
    "Placement": {
      "Base": {
        "x": 0.0,
        "y": 0.0,
        "z": 0.0
      },
      "Rotation": {
        "Axis": {
          "x": 0.0,
          "y": 0.0,
          "z": 1.0
        },
        "Angle": 0.0
      }
    },
    "Shape": {
      "Volume": 36481.57948296661,
      "Area": 12287.114161100935,
      "VertexCount": 56,
      "EdgeCount": 84,
      "FaceCount": 32
    },
    "ViewObject": {
      "ShapeColor": [
        0.7200000286102295,
        0.7400000095367432,
        0.699999988079071,
        0.0
      ],
      "Transparency": 0,
      "Visibility": true
    }
  }
]
Codex

The document currently contains only the spreadsheet and the solid; there are no separate Mesh:: temporary objects to delete. The solid is visible and expression-bound, so I’m checking its display-mode enumeration and using one of those exact values for the reshoot.

execute_code{"code":"import os\nimport FreeCAD as App\nimport FreeCADGui as Gui\n\nDOC_NAME = \"Codex_Dovetail_Q
{
  "code": "import os\nimport FreeCAD as App\nimport FreeCADGui as Gui\n\nDOC_NAME = \"Codex_Dovetail_QuickRelease\"\nPNG_PATH = \"/path/to/freecad-codex-dovetail-quick-release.png\"\ndoc = App.getDocument(DOC_NAME)\nif doc is None:\n    raise RuntimeError(f\"Document {DOC_NAME} is not open\")\nGui.ActiveDocument = Gui.getDocument(doc.Name)\nobj = doc.getObject(\"DovetailQuickRelease\")\nif obj is None:\n    raise RuntimeError(\"DovetailQuickRelease object is missing\")\n\nmesh_objects = []\nfor candidate in list(doc.Objects):\n    type_id = getattr(candidate, \"TypeId\", \"\")\n    if type_id.startswith(\"Mesh::\") or \"Mesh\" in type_id:\n        mesh_objects.append((candidate.Name, type_id))\n        try:\n            candidate.ViewObject.Visibility = False\n        except Exception:\n            pass\n\nenum = list(obj.ViewObject.getEnumerationsOfProperty(\"DisplayMode\"))\nprint(\"DisplayMode enumeration:\", enum)\n\npreferred = None\nfor mode in (\"Shaded\", \"Flat Lines\", \"Wireframe\", \"Points\"):\n    if mode in enum:\n        preferred = mode\n        break\nif preferred is None and enum:\n    preferred = enum[0]\nif preferred is None:\n    raise RuntimeError(\"DisplayMode enumeration is empty\")\nobj.ViewObject.DisplayMode = preferred\nobj.ViewObject.Visibility = True\nobj.ViewObject.ShapeColor = (0.72, 0.74, 0.70, 0.0)\nobj.ViewObject.LineColor = (0.12, 0.12, 0.12, 0.0)\ndoc.recompute()\n\nview = Gui.ActiveDocument.ActiveView\n# Try to ensure the document root object is shown in the 3D view before saving.\ntry:\n    Gui.Selection.clearSelection()\n    Gui.Selection.addSelection(doc.Name, obj.Name)\nexcept Exception:\n    pass\nview.viewIsometric()\nview.fitAll()\ntry:\n    view.setAxisCross(False)\nexcept Exception:\n    pass\nview.saveImage(PNG_PATH, 1400, 1000, \"White\")\n\nprint(\"Hidden mesh objects:\", mesh_objects)\nprint(\"Applied DisplayMode:\", obj.ViewObject.DisplayMode)\nprint(\"PNG overwritten:\", PNG_PATH)\nprint(\"Solid validity:\", obj.Shape.ShapeType, obj.Shape.isValid(), len(obj.Shape.Solids))",
  "timeout_ms": 120000
}
Wall time: 29.8695 seconds
Output:
[{"type":"text","text":"Failed to execute code: RuntimeError: DisplayMode enumeration is empty"}]
Codex

The direct enum call returned an empty list on the custom Part::FeaturePython view provider, which explains the earlier Shaded assignment failure. I’m going to probe the view object’s actual properties and test the available display-mode strings without changing the geometry, then use a plain Part display copy only if the custom view provider cannot render.

execute_code{"code":"import FreeCAD as App\nimport FreeCADGui as Gui\nDOC_NAME = \"Codex_Dovetail_QuickRelease\"
{
  "code": "import FreeCAD as App\nimport FreeCADGui as Gui\nDOC_NAME = \"Codex_Dovetail_QuickRelease\"\ndoc = App.getDocument(DOC_NAME)\nGui.ActiveDocument = Gui.getDocument(doc.Name)\nobj = doc.getObject(\"DovetailQuickRelease\")\nvo = obj.ViewObject\nprint(\"ViewObject type:\", type(vo).__name__)\nprint(\"DisplayMode in PropertiesList:\", \"DisplayMode\" in list(vo.PropertiesList))\nprint(\"Properties containing Display/Mode:\", [p for p in vo.PropertiesList if \"Display\" in p or \"Mode\" in p])\ntry:\n    print(\"getEnumerationsOfProperty DisplayMode:\", list(vo.getEnumerationsOfProperty(\"DisplayMode\")))\nexcept Exception as e:\n    print(\"getEnumerationsOfProperty error:\", repr(e))\ntry:\n    print(\"DisplayModes attribute:\", vo.DisplayModes)\nexcept Exception as e:\n    print(\"DisplayModes attribute error:\", repr(e))\nfor mode in [\"Flat Lines\", \"Shaded\", \"Wireframe\", \"Points\", \"Hidden Line\", \"No Shading\"]:\n    try:\n        vo.DisplayMode = mode\n        print(\"accepted DisplayMode:\", mode, \"->\", vo.DisplayMode)\n    except Exception as e:\n        print(\"rejected DisplayMode:\", mode, \"->\", repr(e))\nprint(\"current DisplayMode value:\", getattr(vo, \"DisplayMode\", None))",
  "timeout_ms": 120000
}
Wall time: 281.7695 seconds
Output:
Code executed successfully: Python code executed successfully.
Output: ViewObject type: ViewProviderPartExt
DisplayMode in PropertiesList: True
Properties containing Display/Mode: ['DisplayMode']
getEnumerationsOfProperty DisplayMode: []
DisplayModes attribute error: AttributeError("'PartGui.ViewProviderPartExt' object has no attribute 'DisplayModes'")
rejected DisplayMode: Flat Lines -> ValueError("'Flat Lines' is not part of the enumeration in Codex_Dovetail_QuickRelease#DovetailQuickRelease.ViewObject.DisplayMode")
rejected DisplayMode: Shaded -> ValueError("'Shaded' is not part of the enumeration in Codex_Dovetail_QuickRelease#DovetailQuickRelease.ViewObject.DisplayMode")
rejected DisplayMode: Wireframe -> ValueError("'Wireframe' is not part of the enumeration in Codex_Dovetail_QuickRelease#DovetailQuickRelease.ViewObject.DisplayMode")
rejected DisplayMode: Points -> ValueError("'Points' is not part of the enumeration in Codex_Dovetail_QuickRelease#DovetailQuickRelease.ViewObject.DisplayMode")
rejected DisplayMode: Hidden Line -> ValueError("'Hidden Line' is not part of the enumeration in Codex_Dovetail_QuickRelease#DovetailQuickRelease.ViewObject.DisplayMode")
rejected DisplayMode: No Shading -> ValueError("'No Shading' is not part of the enumeration in Codex_Dovetail_QuickRelease#DovetailQuickRelease.ViewObject.DisplayMode")
current DisplayMode value: None
Codex

The custom feature’s ViewProviderPartExt exposes a DisplayMode property but its enumeration is empty, so there is no legal string to assign on that view provider. I’m adding a minimal view-provider proxy that supplies real display modes for the existing solid, then I’ll set Shaded, reshoot, and check the PNG pixels.

execute_code{"code":"import os, time\nimport FreeCAD as App\nimport FreeCADGui as Gui\n\nDOC_NAME = \"Codex_Dove
{
  "code": "import os, time\nimport FreeCAD as App\nimport FreeCADGui as Gui\n\nDOC_NAME = \"Codex_Dovetail_QuickRelease\"\nPNG_PATH = \"/path/to/freecad-codex-dovetail-quick-release.png\"\ndoc = App.getDocument(DOC_NAME)\nif doc is None:\n    raise RuntimeError(f\"Document {DOC_NAME} is not open\")\nGui.ActiveDocument = Gui.getDocument(doc.Name)\nobj = doc.getObject(\"DovetailQuickRelease\")\nif obj is None:\n    raise RuntimeError(\"DovetailQuickRelease object is missing\")\n\nclass DovetailQuickReleaseViewProvider:\n    def __init__(self, view_object):\n        view_object.Proxy = self\n    def attach(self, view_object):\n        self.ViewObject = view_object\n    def getDisplayModes(self, view_object):\n        return [\"Shaded\", \"Wireframe\"]\n    def getDefaultDisplayMode(self):\n        return \"Shaded\"\n    def setDisplayMode(self, mode):\n        return mode\n    def onChanged(self, view_object, prop):\n        return None\n    def updateData(self, obj, prop):\n        return None\n    def __getstate__(self):\n        return {}\n    def __setstate__(self, state):\n        return None\n\n# Hide/delete only mesh-type temporary objects. None are expected, but keep the cleanup explicit.\nmesh_objects = []\nfor candidate in list(doc.Objects):\n    type_id = getattr(candidate, \"TypeId\", \"\")\n    if type_id.startswith(\"Mesh::\") or \"Mesh\" in type_id:\n        mesh_objects.append((candidate.Name, type_id))\n        try:\n            candidate.ViewObject.Visibility = False\n        except Exception:\n            pass\n\nDovetailQuickReleaseViewProvider(obj.ViewObject)\ndoc.recompute()\nGui.updateGui()\n\nenum = list(obj.ViewObject.getEnumerationsOfProperty(\"DisplayMode\"))\nprint(\"DisplayMode enumeration after view-provider proxy:\", enum)\nif \"Shaded\" not in enum:\n    raise RuntimeError(f\"Shaded is still not assignable; enum={enum!r}\")\nobj.ViewObject.DisplayMode = \"Shaded\"\nobj.ViewObject.Visibility = True\nobj.ViewObject.ShapeColor = (0.72, 0.74, 0.70, 0.0)\nobj.ViewObject.LineColor = (0.12, 0.12, 0.12, 0.0)\n\n# Hide spreadsheet from the 3D scene and make sure only the solid is visible for the shot.\nsheet = doc.getObject(\"Sheet\")\nif sheet and hasattr(sheet, \"ViewObject\"):\n    sheet.ViewObject.Visibility = False\n\ndoc.recompute()\nGui.updateGui()\nview = Gui.ActiveDocument.ActiveView\ntry:\n    Gui.SendMsgToActiveView(\"ViewFit\")\nexcept Exception:\n    pass\nview.viewIsometric()\nview.fitAll()\nGui.updateGui()\ntime.sleep(0.5)\nview.saveImage(PNG_PATH, 1400, 1000, \"White\")\n\n# Pixel check for the saved PNG without using shell commands.\nfrom PIL import Image\nimg = Image.open(PNG_PATH).convert(\"RGB\")\nnonwhite = 0\nunique = set()\nfor y in range(0, img.height, 5):\n    for x in range(0, img.width, 5):\n        px = img.getpixel((x, y))\n        unique.add(px)\n        if px != (255, 255, 255):\n            nonwhite += 1\n\nprint(\"Hidden mesh objects:\", mesh_objects)\nprint(\"Applied DisplayMode:\", obj.ViewObject.DisplayMode)\nprint(\"PNG overwritten:\", PNG_PATH)\nprint(\"Sampled non-white pixels:\", nonwhite)\nprint(\"Sampled unique colors:\", len(unique))\nprint(\"Solid validity:\", obj.Shape.ShapeType, obj.Shape.isValid(), len(obj.Shape.Solids))",
  "timeout_ms": 120000
}
Wall time: 3.6679 seconds
Output:
Code executed successfully: Python code executed successfully.
Output: DisplayMode enumeration after view-provider proxy: ['Flat Lines', 'Shaded', 'Wireframe', 'Points', 'Shaded', 'Wireframe']
Hidden mesh objects: []
Applied DisplayMode: Shaded
PNG overwritten: /path/to/freecad-codex-dovetail-quick-release.png
Sampled non-white pixels: 0
Sampled unique colors: 1
Solid validity: Solid True 1
Codex

The enum is now populated and Shaded was accepted. The MCP screenshot display shows the corrected shaded view, but the immediate file pixel check ran before the image write had fully landed, so I’m doing a separate read-back check of the PNG now.

execute_code{"code":"from PIL import Image\nPNG_PATH = \"/path/to/freeca
{
  "code": "from PIL import Image\nPNG_PATH = \"/path/to/freecad-codex-dovetail-quick-release.png\"\nimg = Image.open(PNG_PATH).convert(\"RGB\")\nnonwhite = 0\nunique = set()\nfor y in range(0, img.height, 5):\n    for x in range(0, img.width, 5):\n        px = img.getpixel((x, y))\n        unique.add(px)\n        if px != (255, 255, 255):\n            nonwhite += 1\nprint({\"path\": PNG_PATH, \"size\": img.size, \"sampled_nonwhite_pixels\": nonwhite, \"sampled_unique_colors\": len(unique)})",
  "timeout_ms": 120000
}
Wall time: 118.5496 seconds
Output:
Code executed successfully: Python code executed successfully.
Output: {'path': '/path/to/freecad-codex-dovetail-quick-release.png', 'size': (1400, 1000), 'sampled_nonwhite_pixels': 0, 'sampled_unique_colors': 1}
Codex

saveImage() is leaving the file white even though the active view itself is correctly shaded. I’m switching the overwrite to a Qt viewport grab from the same FreeCAD active view, which captures what the MCP view is showing instead of relying on saveImage().

execute_code{"code":"import os, time\nimport FreeCAD as App\nimport FreeCADGui as Gui\n\ntry:\n from PySide6
{
  "code": "import os, time\nimport FreeCAD as App\nimport FreeCADGui as Gui\n\ntry:\n    from PySide6 import QtWidgets, QtGui, QtCore\n    pyside = \"PySide6\"\nexcept Exception:\n    from PySide2 import QtWidgets, QtGui, QtCore\n    pyside = \"PySide2\"\nfrom PIL import Image\n\nDOC_NAME = \"Codex_Dovetail_QuickRelease\"\nPNG_PATH = \"/path/to/freecad-codex-dovetail-quick-release.png\"\ndoc = App.getDocument(DOC_NAME)\nGui.ActiveDocument = Gui.getDocument(doc.Name)\nobj = doc.getObject(\"DovetailQuickRelease\")\nobj.ViewObject.Visibility = True\nobj.ViewObject.DisplayMode = \"Shaded\"\nobj.ViewObject.ShapeColor = (0.72, 0.74, 0.70, 0.0)\nif doc.getObject(\"Sheet\"):\n    doc.getObject(\"Sheet\").ViewObject.Visibility = False\nfor candidate in doc.Objects:\n    type_id = getattr(candidate, \"TypeId\", \"\")\n    if type_id.startswith(\"Mesh::\") or \"Mesh\" in type_id:\n        candidate.ViewObject.Visibility = False\n\ndoc.recompute()\nGui.updateGui()\nview = Gui.ActiveDocument.ActiveView\nview.viewIsometric()\nview.fitAll()\nGui.updateGui()\ntime.sleep(0.5)\n\nviewer = view.getViewer()\nmethods = [m for m in dir(viewer) if \"grab\" in m.lower() or \"snap\" in m.lower() or \"image\" in m.lower()]\nprint(\"Qt binding:\", pyside)\nprint(\"Viewer class:\", type(viewer).__name__)\nprint(\"Viewer image/grab methods:\", methods)\n\nsaved = False\n# QWidget.grab() captures the rendered viewport contents.\ntry:\n    pixmap = viewer.grab()\n    if not pixmap.isNull():\n        pixmap = pixmap.scaled(1400, 1000, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)\n        canvas = QtGui.QPixmap(1400, 1000)\n        canvas.fill(QtGui.QColor(\"white\"))\n        painter = QtGui.QPainter(canvas)\n        painter.drawPixmap((1400 - pixmap.width()) // 2, (1000 - pixmap.height()) // 2, pixmap)\n        painter.end()\n        saved = canvas.save(PNG_PATH, \"PNG\")\n        print(\"viewer.grab save result:\", saved, \"grab size:\", pixmap.width(), pixmap.height())\nexcept Exception as e:\n    print(\"viewer.grab failed:\", repr(e))\n\nif not saved:\n    try:\n        screen = QtWidgets.QApplication.primaryScreen()\n        pixmap = screen.grabWindow(int(viewer.winId()))\n        if not pixmap.isNull():\n            pixmap = pixmap.scaled(1400, 1000, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)\n            canvas = QtGui.QPixmap(1400, 1000)\n            canvas.fill(QtGui.QColor(\"white\"))\n            painter = QtGui.QPainter(canvas)\n            painter.drawPixmap((1400 - pixmap.width()) // 2, (1000 - pixmap.height()) // 2, pixmap)\n            painter.end()\n            saved = canvas.save(PNG_PATH, \"PNG\")\n            print(\"screen.grabWindow save result:\", saved, \"grab size:\", pixmap.width(), pixmap.height())\n    except Exception as e:\n        print(\"screen.grabWindow failed:\", repr(e))\n\nimg = Image.open(PNG_PATH).convert(\"RGB\")\nnonwhite = 0\nunique = set()\nfor y in range(0, img.height, 5):\n    for x in range(0, img.width, 5):\n        px = img.getpixel((x, y))\n        unique.add(px)\n        if px != (255, 255, 255):\n            nonwhite += 1\nprint(\"DisplayMode enumeration:\", list(obj.ViewObject.getEnumerationsOfProperty(\"DisplayMode\")))\nprint(\"Applied DisplayMode:\", obj.ViewObject.DisplayMode)\nprint(\"PNG overwritten:\", PNG_PATH)\nprint(\"Saved:\", saved)\nprint(\"Sampled non-white pixels:\", nonwhite)\nprint(\"Sampled unique colors:\", len(unique))",
  "timeout_ms": 120000
}
Wall time: 338.0586 seconds
Output:
Code executed successfully: Python code executed successfully.
Output: Qt binding: PySide6
Viewer class: View3DInventorViewerPy
Viewer image/grab methods: ['grabFramebuffer']
viewer.grab failed: AttributeError('grab')
screen.grabWindow failed: AttributeError('winId')
DisplayMode enumeration: ['Flat Lines', 'Shaded', 'Wireframe', 'Points', 'Shaded', 'Wireframe']
Applied DisplayMode: Shaded
PNG overwritten: /path/to/freecad-codex-dovetail-quick-release.png
Saved: False
Sampled non-white pixels: 0
Sampled unique colors: 1
Codex

The viewport object exposes grabFramebuffer(), not QWidget grab(). I’m using that exact viewer method now and writing the framebuffer to the PNG path, then reading the file back for a non-white pixel count.

execute_code{"code":"import os, time\nimport FreeCAD as App\nimport FreeCADGui as Gui\nfrom PIL import Image\n\n
{
  "code": "import os, time\nimport FreeCAD as App\nimport FreeCADGui as Gui\nfrom PIL import Image\n\ntry:\n    from PySide6 import QtGui, QtCore\nexcept Exception:\n    from PySide2 import QtGui, QtCore\n\nDOC_NAME = \"Codex_Dovetail_QuickRelease\"\nPNG_PATH = \"/path/to/freecad-codex-dovetail-quick-release.png\"\ndoc = App.getDocument(DOC_NAME)\nGui.ActiveDocument = Gui.getDocument(doc.Name)\nobj = doc.getObject(\"DovetailQuickRelease\")\nobj.ViewObject.DisplayMode = \"Shaded\"\nobj.ViewObject.Visibility = True\nif doc.getObject(\"Sheet\"):\n    doc.getObject(\"Sheet\").ViewObject.Visibility = False\nfor candidate in doc.Objects:\n    type_id = getattr(candidate, \"TypeId\", \"\")\n    if type_id.startswith(\"Mesh::\") or \"Mesh\" in type_id:\n        candidate.ViewObject.Visibility = False\n\ndoc.recompute()\nview = Gui.ActiveDocument.ActiveView\nview.viewIsometric()\nview.fitAll()\nGui.updateGui()\ntime.sleep(0.5)\nviewer = view.getViewer()\nfb = viewer.grabFramebuffer()\nprint(\"Framebuffer type:\", type(fb).__name__)\nprint(\"Framebuffer isNull:\", fb.isNull() if hasattr(fb, \"isNull\") else None)\nprint(\"Framebuffer size:\", fb.width() if hasattr(fb, \"width\") else None, fb.height() if hasattr(fb, \"height\") else None)\n\nsaved = False\nif isinstance(fb, QtGui.QImage):\n    scaled = fb.scaled(1400, 1000, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)\n    canvas = QtGui.QImage(1400, 1000, QtGui.QImage.Format_RGB32)\n    canvas.fill(QtGui.QColor(\"white\"))\n    painter = QtGui.QPainter(canvas)\n    painter.drawImage((1400 - scaled.width()) // 2, (1000 - scaled.height()) // 2, scaled)\n    painter.end()\n    saved = canvas.save(PNG_PATH, \"PNG\")\nelif isinstance(fb, QtGui.QPixmap):\n    scaled = fb.scaled(1400, 1000, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)\n    canvas = QtGui.QPixmap(1400, 1000)\n    canvas.fill(QtGui.QColor(\"white\"))\n    painter = QtGui.QPainter(canvas)\n    painter.drawPixmap((1400 - scaled.width()) // 2, (1000 - scaled.height()) // 2, scaled)\n    painter.end()\n    saved = canvas.save(PNG_PATH, \"PNG\")\nelse:\n    # Some FreeCAD builds return bytes-like data; fail loudly if so.\n    raise RuntimeError(f\"Unsupported framebuffer result: {type(fb)!r}\")\n\nimg = Image.open(PNG_PATH).convert(\"RGB\")\nnonwhite = 0\nunique = set()\nfor y in range(0, img.height, 5):\n    for x in range(0, img.width, 5):\n        px = img.getpixel((x, y))\n        unique.add(px)\n        if px != (255, 255, 255):\n            nonwhite += 1\nprint(\"DisplayMode enumeration:\", list(obj.ViewObject.getEnumerationsOfProperty(\"DisplayMode\")))\nprint(\"Applied DisplayMode:\", obj.ViewObject.DisplayMode)\nprint(\"PNG overwritten:\", PNG_PATH)\nprint(\"Saved:\", saved)\nprint(\"Sampled non-white pixels:\", nonwhite)\nprint(\"Sampled unique colors:\", len(unique))",
  "timeout_ms": 120000
}
Wall time: 13.2285 seconds
Output:
Code executed successfully: Python code executed successfully.
Output: Framebuffer type: QImage
Framebuffer isNull: False
Framebuffer size: 2408 1168
DisplayMode enumeration: ['Flat Lines', 'Shaded', 'Wireframe', 'Points', 'Shaded', 'Wireframe']
Applied DisplayMode: Shaded
PNG overwritten: /path/to/freecad-codex-dovetail-quick-release.png
Saved: True
Sampled non-white pixels: 38080
Sampled unique colors: 269
Codex

Fixed the PNG at:

freecad-codex-dovetail-quick-release.png

What I did through the FreeCAD MCP path:

  • Checked document contents: only Sheet and DovetailQuickRelease; no Mesh:: temporary objects existed.
  • Printed/probed the DisplayMode enumeration. The original custom view provider had an empty enum, so I attached a minimal view-provider proxy for the existing solid.
  • Reprinted enum after that: ['Flat Lines', 'Shaded', 'Wireframe', 'Points', 'Shaded', 'Wireframe'].
  • Set DovetailQuickRelease.ViewObject.DisplayMode = 'Shaded'.
  • Re-shot the isometric view using the FreeCAD viewport framebuffer and overwrote the same PNG path.
  • Read the PNG back and verified it is non-empty: 38080 sampled non-white pixels, 269 sampled unique colors.

The new PNG is shaded and does not include the tessellation validation mesh lines.