# Freecad Automation

> FreeCAD Automation

- Skill: `trailcurrentoss/freecad-automation` (Agent Skill)
- Install (CLI): `npx skillmds@latest add trailcurrentoss/freecad-automation`
- Raw SKILL.md: https://api.skillmd.com/api/skills/trailcurrentoss/freecad-automation/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: trailcurrentoss (https://skillmd.com/u/trailcurrentoss)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/trailcurrentoss/freecad-automation

---


# FreeCAD Automation

Create and modify 3D CAD models, generate exports, and automate FreeCAD operations. Always use the PartDesign workbench for building models — never Part booleans.

## Instructions

You are automating FreeCAD operations. This skill targets **FreeCAD 1.1 or later**, invoked as `$FREECAD_BIN`.

**Never hardcode the path to the FreeCAD binary.** Resolve it in this order: ask the user and offer to save the answer to a gitignored local config; accept it as a parameter; read `$FREECAD_BIN` from the environment; otherwise fail with a message naming all three. Never fall back to a default that happens to be one developer's install.

Which install the user has matters. A snap or flatpak build is **sandboxed** and cannot read `/tmp` or paths outside the user's home; an AppImage or distro package can.

### MCP Server (Primary Method — ALWAYS try first)

The `freecad` MCP server is configured globally. **Always use MCP tools first.** If the MCP connection fails, **ask the user to start FreeCAD and enable the MCP server** (TrailCurrent Logo workbench → MCP Server button, port 12785) before falling back to headless scripting.

Key MCP tools:
- `execute_code` — Run arbitrary Python in FreeCAD (most powerful)
- `list_documents` / `get_document_info` — Inspect open documents
- `get_object_properties` / `set_object_property` — Read/write object data
- `capture_screenshot` — See the current 3D view (save as PNG, then read the file)
- `fit_view` — Fit camera to all objects
- `add_object` / `remove_object` — Create/delete objects
- `export_object` — Export to STEP/STL/BREP/glTF
- `import_file` — Import external CAD files

The MCP server runs on port 12785 (XML-RPC). The `execute_code` tool runs code on FreeCAD's main thread with these modules pre-imported: `FreeCAD`/`App`, `FreeCADGui`/`Gui`, `Part`, `Mesh`, `Draft`, `Sketcher`, `PartDesign`, `Import`, `BOPTools`.

### Headless Scripting (Fallback ONLY)

Only use headless scripting if the user explicitly says FreeCAD is unavailable or requests a script-based approach:

```bash
# Run a Python script headlessly
$FREECAD_BIN -c "exec(open('/tmp/script.py').read())"

# Run inline Python
$FREECAD_BIN -c "import FreeCAD; print(FreeCAD.Version())"
```

Note: Headless mode has no GUI — `FreeCADGui`/`Gui` is unavailable, so no screenshots or selection queries.

### MCP XML-RPC Direct Access

When MCP tools are not registered in the session but the server is running, you can call the server directly via Python `xmlrpc.client`. The method name is `execute` (not `execute_code`):

```python
import xmlrpc.client
s = xmlrpc.client.ServerProxy('http://localhost:12785')
result = s.execute("print('hello from FreeCAD')")
print(result)  # {'stdout': 'hello from FreeCAD\n'}
```

Check if the server is up: `curl -s --max-time 2 http://localhost:12785` (returns 501 HTML if running).

Ask the server what it exposes instead of guessing:

```python
s.system.listMethods()          # ['execute', 'ping', 'screenshot', ...]
s.system.methodHelp('screenshot')
```

**`execute()` takes a timeout — pass it instead of chunking.** The default is 60 s,
and hitting it looks like a hang, which tempts you into splitting a job into
artificial pieces. It is a parameter, not a limit:

```python
s.execute(code, 300)            # slow boolean ops, big recomputes, batch pads
```

Set the client socket timeout higher too, or `xmlrpc.client` gives up first:

```python
socket.setdefaulttimeout(320)
```

Long booleans (`Shape.common`, `distToShape`) across dozens of solids and
multi-body PartDesign builds routinely exceed 60 s. Raise the timeout; do not
split the work and do not silently drop coverage to fit.

---

## MCP Failure Modes — read before diagnosing "the MCP is broken"

These cost real hours. Each looks like a different problem than it is. **Fix the
cause; do not work around it** — a workaround here gets re-derived by the next agent
at full cost, which is the whole reason this section exists.

### 0. "FreeCAD is not responding / Force Quit" → YOU froze it with a long `execute_code`

**This is the most common way an agent breaks the user's session, and it keeps
happening.** `execute_code` runs on FreeCAD's **GUI thread**. Anything slow you send
freezes the entire application — the window stops repainting and the desktop offers
the user a *Wait / Force Quit* dialog. From the agent side nothing looks wrong: the
call eventually returns a correct answer. The user, meanwhile, has been staring at a
dead window and may well have killed it, losing unsaved work.

Operations measured on this project that are long enough to trigger it:

| call | cost |
|---|---|
| `Mesh.Mesh.getSegmentsOfType("Cylinder", …)` on a 108 k-facet mesh | **30 s** |
| `getPlanarSegments` on the same mesh | seconds to tens of seconds |
| any Python `for` loop over >10 k facets / vertices | seconds, scales badly |
| `Shape.common` / `distToShape` across many solids | tens of seconds |
| whole-document `recompute()` on a large assembly | tens of seconds |

**Rules:**

- **Keep every `execute_code` call under ~2 s.** If you cannot bound the runtime,
  do not send it.
- **Do not send bulk analysis loops to FreeCAD at all.** Mesh statistics, feature
  extraction, hole-finding, point-cloud fitting and n² interference sweeps are batch
  work; they do not need the GUI and must not run on its thread. Run them in a
  separate process against the source file, or use the scoped MCP tools.
- Prefer the convenience tools (`measure`, `sketch_info`, `get_object_properties`,
  `spreadsheet_get`, `get_errors`) — they are individually cheap by construction.
- Scope recomputes: `doc.recompute([obj1, obj2])`, never bare `doc.recompute()` on a
  big document.
- **A slow call is not "working" — it is a frozen application.** If the user reports
  the not-responding dialog, stop sending code, confirm the app is alive with a
  trivial call such as `list_documents`, and apologise for the freeze rather than
  retrying the same heavy call.

Note this is the opposite failure mode to §1 below: there, a dialog blocks *you*;
here, *you* block the user.

### 1. Every call times out → a modal dialog is blocking the main thread

`execute()` marshals code onto FreeCAD's GUI thread. Any modal dialog raised by
library code spins a nested event loop *on that thread*, so every later call returns
`Execution timed out after 60s` forever. You cannot click it and you cannot dismiss
it over the MCP, because dismissing it needs the thread it is blocking.

The classic trigger is `importDXF` asking permission to download its helper
libraries. **Never set `dxfUseLegacyImporter=True`** — FreeCAD 1.x has a built-in
importer and the legacy path prompts.

Servers from **v2.5.3** guard this automatically: `QMessageBox.*` and
`QDialog/QFileDialog/QInputDialog/QProgressDialog.exec` are stubbed while MCP code
runs, and whatever was suppressed comes back in a `dialogs` key. If you see that key,
**an operation silently took a default branch** — treat it as a warning, not noise.

If you are on an older server and it is already wedged, the only recovery is the user
clicking the dialog. Say so plainly and ask; do not screenshot the desktop and
improvise.

### 2. Blank screenshot → almost always visibility, not the renderer

`saveImage()` works. Before blaming the offscreen framebuffer or the GPU, check what
is actually visible. **In PartDesign the Body's `Tip` feature is what renders**, not
the Body:

```python
body.ViewObject.Visibility = True          # NOT sufficient on its own
body.Tip.ViewObject.Visibility = True      # this is what draws
```

An `App::Link` renders nothing when its source Body's Tip is hidden, and hiding an
`Assembly`/`App::Part` container hides every child link. Turning off `pad_*` objects
blanks the whole model.

`screenshot()` (v2.5.3+) checks the image and warns instead of returning an empty
PNG. Trust that warning — it is usually right and you are usually wrong.

### 2b. "My fix keeps reverting" → several documents are bound to ONE file

FreeCAD will happily hold multiple open documents pointing at the same `.FCStd`. The
Report view announces it, and it is easy to scroll past:

```
Physical path: .../lr4_table_clean.FCStd
  Document: lr4_table_clean (lr4_clean): .../lr4_table_clean.FCStd
  Document: lr4_table_clean (lr4_v2):   .../lr4_table_clean.FCStd
  Document: lr4_table_clean (lr4_v3):   .../lr4_table_clean.FCStd
```

`App.ActiveDocument` and `App.getDocument(name)` then resolve to whichever one FreeCAD
picked, which may not be the one the GUI is showing. You fix visibility in `lr4_v2`, the
user is looking at `lr4_clean`, and your change appears not to have happened. Save from
one and it overwrites the other's version of the file.

Symptom to recognise: **the same fix "coming back" two or three times**, with the user
insisting they did not undo it. Before blaming undo or the user, check:

```python
for d in App.listDocuments().values():
    print(d.Name, d.Label, d.FileName)     # more than one row for a path == this bug
```

`list_documents` shows the same thing. Resolve it by closing the duplicates before
doing any more work; there is no way to merge them.

### 3. Plugin edits have no effect → you edited the wrong copy

The repo working copy and the installed plugin are **different directories**. The
running server loads from FreeCAD's Mod folder. Verify before concluding a fix failed:

```bash
diff -q <repo>/mcp/rpc_server.py "$(ls -d ~/.local/share/FreeCAD/v*/Mod/*LogoFreeCADPlugin)/mcp/rpc_server.py"
```

FreeCAD 1.0+ uses a **versioned** user path — `~/.local/share/FreeCAD/v1-1/Mod`, not
`~/.local/share/FreeCAD/Mod`. Anything looking only at the unversioned path silently
misses a 1.x install.

Reloading a changed module needs a **FreeCAD restart**; toggling the workbench or the
MCP Server button is not enough.

### 4. Exporting DXF — two obvious routes are traps

- `importDXF.export()` needs a helper library it will **prompt to download**.
  That prompt is the deadlock in §1. Never call it.
- `TechDraw.writeDXFView()` ships with FreeCAD and looks like the safe answer.
  Verified on FreeCAD 1.1: it writes a **4 KB file containing zero entities**,
  for sketches *and* for solids. It reports no error.

Use the `export_dxf` MCP tool (v2.5.6+), which writes LINE entities straight
from shape edges, discretising curves. No library, 1:1 geometry, and it counts
the entities before reporting success.

The general lesson: **a file existing is not an export.** Check entity count and
extents, never `os.path.getsize() > 0`.

Two API details that bite when writing geometry out by hand:

- `Edge.discretize()` takes **`Deflection`**, not `Deviation`. `Deviation` raises
  `Part.OCCError: Wrong arguments`. Wrapped in a bare `except` that falls back to
  edge endpoints, this is invisible on straight lines and silently exports every
  arc as **a single chord**. Verify with a known circle: radius 10 at
  `Deflection=0.05` gives 33 points, not 2.
- `DocumentObject` has **no `isError()`** method. Use `obj.isValid()` and
  `obj.State` (a list, e.g. `['Up-to-date']`, `['Touched']`, `['Invalid']`).
  Calling `isError()` raises `AttributeError` on PartDesign features.

### 5. Bump `<version>` in `package.xml` on every plugin change

The FreeCAD Addon Manager compares versions and **ignores anything not newer**, so an
un-bumped fix will not install no matter how many times it is pushed. Semver: patch
for fixes, minor for features. Version only ever goes up. This applies to any edit
under the plugin, including the MCP server that lives inside it.

The Addon Manager pulls from the **GitHub remote**, not the local working copy — a fix
must be committed and pushed before it can be installed that way.

---

## Assemblies of `App::Link` instances — the four traps

The correct structure is: one Body per unique part, instanced into an
`Assembly::AssemblyObject` with `App::Link`. Everything below is what goes wrong with
that structure, and every item cost a full round trip with the user on the LR4 torsion
table (7 bodies, 33 links).

### 1. Ghost sketches and planes — a Body's internals are drawn by EVERY link

An `App::Link` renders the linked Body's children according to **the children's own
visibility**, read from the source. The Body's own `Visibility` is overridden per-link;
its children's is not, and there is no per-instance override.

So one sketch left visible inside `B_CROSS` is drawn 12 times, once at each rib. The
user sees translucent panels standing in every cell and blue outlines threading through
the model, and reasonably concludes the assembly contains junk parts.

**Diagnose in one step — hide the assembly container.** If the 3D view goes *completely
empty*, the ghosts were coming through the links, not from loose geometry parked at the
origin. Do this before theorising; it takes one call and it is decisive.

**The canonical state for every Body** is: Tip visible, every other feature hidden,
every sketch hidden, every Origin child hidden. Audit all of them at once:

```python
for b in [o for o in doc.Objects if o.TypeId == "PartDesign::Body"]:
    shown = [f.Name for f in b.Group
             if f.TypeId.startswith("PartDesign::") and f.ViewObject.Visibility]
    sk  = [f.Name for f in b.Group
           if f.TypeId == "Sketcher::SketchObject" and f.ViewObject.Visibility]
    org = [c.Name for c in b.Origin.OutList if c.ViewObject.Visibility]
    ok = shown == [b.Tip.Name] and not sk and not org
```

**When listing "what is visible", do NOT filter out `App::Origin` / `App::Plane` /
`App::Line` / `App::Point`.** Skipping them as noise is how the big translucent panel
goes unexplained for several rounds — origin planes are exactly what a large light-blue
sheet in the viewport is.

### 2. Space in the 3D view hides the MASTER, blanking every instance

3D-view selection resolves to a **path**, and Space acts on its **last element**:

```python
Gui.Selection.getSelectionEx('', 0)[0].SubElementNames
# ('A_YSPAR_04B.dogbones_LONG_B.',)   <- link . master Tip feature
```

Pressing Space there hides `dogbones_LONG_B` — the Tip of the shared Body — so all 8
links to it go blank at once while `Visibility` on every link still reads `True`. The
tree highlights the link (head of the path), so it looks like the link was selected.

Symptom to recognise: *"I hid one rib and a whole group disappeared."* Confirm with
`hidden links: none` plus a Tip at `False`. The fix is to toggle the **link**:

```python
doc.getObject("A_YSPAR_04B").ViewObject.Visibility = False   # hides exactly one
```

For the user: click the link in the **Model tree**, not in the 3D view, then Space.

**There is no document-side fix. Do not offer one.** All of the following were tested on
FreeCAD 1.1 with `Gui.Selection.addSelection(...)` + `Gui.runCommand("Std_ToggleVisibility")`,
and every one still hid the master:

| configuration tried | result |
|---|---|
| link → Body, same document | master hidden |
| link → Body in a **separate saved document**, real `Assembly::AssemblyObject` | master hidden |
| `LinkClaimChild = True` | master hidden |
| link **array** (`ElementCount=3`, `ShowElement=True`) | master hidden, `VisibilityList` untouched |
| `Std_HideSelection`, `ksuToolsVisibilityToggle` instead of `Std_ToggleVisibility` | master hidden |
| one-level path `"A_XRIB_05."` (what a **tree** click produces) | **link only — correct** |

Only the path depth matters. Rebuilding the bodies does not change it either — a
freshly rebuilt body whose Tip is a `LinearPattern` behaves identically to an old one.

So: joints do **not** fix it, moving masters to a parts file does **not** fix it, and
rebuilding does **not** fix it. Say that plainly. Suggesting a restructure "which is
probably why you haven't hit this before" is a guess, and on this model it was wrong —
run the two-line probe above before claiming any structure change will help.

Same trap in reverse: to hide a skin, hide `A_BOT_SKIN` (the link), never `pad_BOT_SKIN`
(the master Tip).

### 3. Transact view-state fixes, and SAVE them

A visibility cleanup that is not saved is not a fix. A `Ctrl+Z`, File → Revert, or
reload brings every ghost straight back, the user reports the same bug again, and you
burn another round re-diagnosing something you already solved. This happened **three
times** in one session.

```python
doc.openTransaction("Clean body display state")   # one undo step, not 65
...
doc.commitTransaction()
```

Without the transaction the user's undo unpicks the cleanup *partially*, mixing it with
their own edits — which is how a hidden `dogbones_LONG_B` ended up interleaved with the
sketch fix and looked like a new failure.

Leaving work unsaved "so the user can back out" is the wrong default here. Back it up
(`cp x.FCStd x.backup.FCStd`), apply, save, and say what you saved.

### 4. An empty `JointGroup` is not an assembly

`Assembly::AssemblyObject` happily contains an `Assembly::JointGroup` with zero joints,
and every part positioned by a hardcoded `Placement`. It looks assembled and is not.

```python
len(doc.getObject("Joints").Group)        # 0  -> nothing is constrained
[o for o in doc.Objects if "Ground" in o.TypeId]   # [] -> nothing grounded
doc.getObject("A_XRIB_01").ExpressionEngine        # [] -> placement is a typed number
```

Check all three before reporting an assembly as built.

---

## Scripted builds leave the document dirty — add a tidy-up pass

`doc.addObject("Sketcher::SketchObject", ...)` creates the sketch **visible**, and each
`PartDesign::Body` gets an Origin whose planes and axes are visible too. Hand-modelling
in the GUI hides these for you; scripting does not. A generated document therefore ships
with every profile sketch and origin plane switched on — invisible to the author, who is
looking at a single body, and glaring the moment those bodies are instanced.

**End every build script with:**

```python
for o in doc.Objects:
    if o.TypeId in ("Sketcher::SketchObject", "App::Origin", "App::Plane",
                    "App::Line", "App::Point"):
        o.ViewObject.Visibility = False
for b in [o for o in doc.Objects if o.TypeId == "PartDesign::Body"]:
    for f in b.Group:
        if f.TypeId.startswith("PartDesign::"):
            f.ViewObject.Visibility = (f is b.Tip)
```

Fixing the generated file without fixing the generator means it comes back on the next
rebuild. Fix both.

---

## "Parametric" needs checking, not assuming

A document can contain a beautifully aliased `Spreadsheet` that **drives nothing**. On
the LR4 table the sheet had `thickness`, `rib_height`, `core_w`… and every sketch was a
polyline of absolute coordinates held together by `Coincident` constraints alone:

```python
sk.Constraints          # 60 x Coincident, zero dimensional
sk.FullyConstrained     # False
sk.ExpressionEngine     # []
link.ExpressionEngine   # []  -- placements are typed numbers too
```

Changing `thickness` moved nothing. Three cheap checks that catch this:

| check | means |
|---|---|
| `sk.FullyConstrained` is `False` | geometry is floating, not driven |
| `sk.ExpressionEngine == []` | no constraint is bound to the sheet |
| `link.ExpressionEngine == []` | assembly placement is a hardcoded number |

The real test is still the one further down this file: change one cell, `recompute()`,
confirm geometry moved.

**Verify derived formulas against measured geometry before building on them.** Reading
`Radius : 3.175, Position : (20.2339, 39.0299, 0)` out of the existing dogbone sketch
gave `dog_off = cutter_r * (1 - 1/sqrt(2))` = 0.9299359697, matching the modelled
0.9299 exactly — proof the formula was right, obtained without trusting any arithmetic
of mine.

### MCP tool gap: there is no sketch-constraint tool

`sketch_add_polyline` adds **coincident constraints only**. `set_expression` binds an
*existing* constraint but cannot create one. So dimensional constraints — the thing that
actually makes a sketch parametric — require `execute_code` or the GUI. Say this to the
user rather than quietly producing a sketch that looks parametric and is not.

What *is* achievable with the convenience tools alone: grid/repetition parametrics via
`partdesign_pattern` with `Occurrences` and `Length` bound by `set_expression` to sheet
aliases.

### Three silent failures when building a slotted part

All three report success. All three were caught by **one volume comparison** against the
uncut blank, and by nothing else — `get_errors()` said "document is clean" throughout.

**A pocket from a sketch on the pad's base plane cuts into thin air.** Sketch on
`XY_Plane`, pad `0 → +Z`; a Pocket from that same sketch defaults to `-Z`, away from the
material. It removes **nothing**, raises no error, and `valid: True`. Cut both ways
instead — but use the *current* property, not the deprecated one:

```python
p.SideType = "Symmetric"        # enum: 'One side', 'Two sides', 'Symmetric'
```

**`Midplane` is deprecated in FreeCAD 1.1** and is now an alias for `SideType` on
`FeatureExtrude`. Setting it works, but every recompute logs a deprecation warning, and
the shim maps an explicit `Midplane=False` to `SideType='One side'` — the setting that
cuts nothing. After the mapping the two disagree (`Midplane=False` while
`SideType='Symmetric'`); **`SideType` is authoritative**, so read that, not `Midplane`,
when checking whether a cut is configured correctly.

The MCP's `partdesign_feature` sets `midplane` explicitly on every call, so it always
trips this. Create the pocket with the tool, then set `SideType` directly afterwards.

**`partdesign_pattern` appends the pattern at the END of the Body and does not move
`Tip`.** After patterning, `Tip` still points at whatever feature was current, so every
patterned instance silently drops out of the result shape. Symptom: the body looks
under-cut by exactly the patterned features. Always re-assert the Tip:

```python
b.Tip = doc.getObject("LinearPattern")   # last feature in the real chain
```

**A spreadsheet edit does not touch its dependents over the MCP.** `recompute` returns
`Recomputed 0 objects` and the model does not move, which reads as "the binding is
broken" when the binding is fine. Force it:

```python
for o in doc.Objects: o.touch()
doc.recompute()
```

The check that catches all of it, and the one to run after any slotted/pocketed build:

```python
blank = L * W * T                                   # the uncut stock
print("removed %.1f mm3" % (blank - b.Shape.Volume))
# and prove the cuts landed where intended, not merely that volume left:
for x in slot_centres:
    assert not sh.isInside(App.Vector(x, y_mid, t/2), 1e-6, True), "slot not cut"
for x in web_centres:
    assert sh.isInside(App.Vector(x, y_mid, t/2), 1e-6, True), "web missing"
```

`removed == 0.0` exactly, or a volume that equals `L*W*T` to the decimal, means no cut
happened. `isInside` at the intended centres is what distinguishes "material was
removed" from "material was removed **in the right places**".

---

## Use the MCP's convenience tools; reserve `execute_code` for real gaps

`execute_code` is a legitimate MCP tool, but reaching for it to poke around — dumping
object lists, reading properties, checking state — reads to the user as hacking rather
than working, especially when `get_object_properties`, `sketch_info`, `get_errors`,
`spreadsheet_get`, `set_visibility` and `list_documents` answer the same question.

Prefer, in order:

1. The dedicated tool (`set_visibility` handles Tip/Link/container traps for you, and
   takes a comma-separated list, so 65 toggles is 3 calls).
2. `execute_code` where **no tool exists** — Sketcher constraints, joint creation,
   multi-object audits.

Also: reading the project's build script with `grep`/`sed` when the live document can
answer the question is the same mistake. Ask the model, not the source.

Minor API note: `App.Document` has **no `.Modified`** attribute; checking it raises
`AttributeError`.

---

## CRITICAL: Confirm Orientation and Requirements BEFORE Building

**Never start building a CAD model without first confirming the coordinate orientation and key design decisions with the user.** This avoids costly rebuild cycles.

Before any model build:
1. **Navigate to FRONT view** (`Gui.activeDocument().activeView().viewFront()`) and confirm with the user what should be visible
2. **Confirm the coordinate system**: which plane is the main face, which axis is depth, which way is up
3. **Confirm design requirements**: wall thickness, open/closed faces, faceplate, mounting method, cable openings
4. **Only then start building**

FreeCAD standard orientation:
- **FRONT view** looks along **-Y** (XZ plane visible)
- **TOP view** looks along **-Z** (XY plane visible)
- **RIGHT view** looks along **-X** (YZ plane visible)

For a display/screen device oriented naturally (screen facing the user):
- Screen face on **XZ plane** at Y=0, facing **-Y** (toward FRONT)
- Depth along **+Y** (toward BACK)
- Width along **X**, height along **Z** (up)

---

## Verify with the model, not with your own arithmetic

You computed the numbers, so re-deriving them proves nothing. Ask FreeCAD what it
actually built. These MCP tools exist for it and are the fastest way to catch a
real defect:

| tool | catches |
|---|---|
| `measure(a, b)` | wrong size, gap where parts should mate, unintended overlap |
| `check_interference("*")` | parts occupying the same space anywhere in an assembly |
| `get_errors()` | features that failed to recompute — the tree holds broken features silently |
| `capture_screenshot` | geometry that is missing rather than merely misplaced |

In this project's build, measuring the assembly caught a part hanging 0.75"
past the table edge that every placement number said was correct, and an
interference sweep confirmed 108 egg-crate joints mated with 0 gap and 0
overlap. Neither was visible in the code.

Two habits that follow from it:

- **Assert the invariant, not the output.** "sum of parts == overall dimension"
  and "intersection volume == 0" survive a redesign; a hardcoded 66.125 does not.
- **Distrust your own success message.** Before reporting a step done, read back
  what landed: entity counts, bounding boxes, `get_errors()`. Several times in
  this project a step printed success while producing nothing — a blank
  screenshot, an empty DXF, a hidden assembly.

---

## CRITICAL: Parametric first — never bake numbers into the model

FreeCAD's API *is* Python, so scripting is not the problem. What matters is what
the script leaves behind:

- **Wrong:** compute values in Python and write literals into properties. The
  result is dead geometry that only the script's author can change.
- **Right:** use Python to build **native parametric objects** — a Spreadsheet of
  aliased inputs, properties bound by expression, sketch constraints, PartDesign
  patterns. The model then recomputes itself and the user drives it from the GUI.

Put every design input in a Spreadsheet with aliases, derive the rest with
spreadsheet formulas, and bind geometry to the aliases:

```python
sh = doc.addObject("Spreadsheet::Sheet", "Design")
sh.set("B4", "19.05 mm"); sh.setAlias("B4", "thickness")
sh.set("B5", "=thickness + slot_clear"); sh.setAlias("B5", "slot_w")   # derived
pad.setExpression("Length", "Design.thickness")                        # bound
doc.recompute()
```

`sh.set()` takes **strings only** — `sh.set("B4", 5)` raises `TypeError`. Use
`str(value)`. Quantities carry units (`"19.05 mm"`, `"3 in"`); formulas start
with `=` and reference other aliases by name.

Test it the way a user would: change one cell, `recompute()`, and confirm the
geometry moved. If nothing changed, the model is not parametric — it just looks
like it is.

Use the MCP tools `spreadsheet_set`, `spreadsheet_get`, `set_expression` and
`clear_expression` (v2.5.5+) rather than hand-rolling this through `execute_code`.

Repetition belongs to `PartDesign::LinearPattern` / `PolarPattern` / `Mirrored`,
not to a Python loop that creates N independent bodies — unless the parts really
are separately manufactured, in which case separate Bodies are correct.

---

## CRITICAL: Always Use PartDesign Workbench

**Never use Part workbench booleans** (`Part::Cut`, `Part::Fuse`, `Part::Box`, etc.) for building models. Always use the PartDesign workbench (`PartDesign::Body`, `Sketcher::SketchObject`, `PartDesign::Pad`, `PartDesign::Pocket`, etc.).

Why: PartDesign creates a proper parametric feature tree that users can easily edit. Part booleans create a tangled dependency graph that is fragile and hard to modify.

### PartDesign Feature Tree Pattern

Every model follows this structure:

```
PartDesign::Body
├── Sketcher::SketchObject  →  PartDesign::Pad      (base shape)
├── Sketcher::SketchObject  →  PartDesign::Pocket    (hollowing, cuts)
├── Sketcher::SketchObject  →  PartDesign::Pad       (additive features)
├── PartDesign::Plane        →  Sketch → Pocket      (side-face cuts via datum planes)
├── PartDesign::Mirrored                              (symmetry)
└── PartDesign::Fillet / Chamfer                      (edge treatment)
```

For multi-part designs (e.g., enclosure + cover), use **separate PartDesign::Body objects** in the same document.

---

## PartDesign Recipes

### Creating a Body and Base Pad

```python
doc = App.ActiveDocument or App.newDocument("MyModel")

body = doc.addObject("PartDesign::Body", "Enclosure")

# Sketch on XY origin plane
sk = doc.addObject("Sketcher::SketchObject", "sk_Base")
body.addObject(sk)
sk.AttachmentSupport = [(doc.getObject("XY_Plane"), "")]
sk.MapMode = "FlatFace"

# Draw a rectangle centered at origin
L, W = 100.0, 50.0
pts = [(-L/2, -W/2), (L/2, -W/2), (L/2, W/2), (-L/2, W/2)]
for i in range(4):
    x1, y1 = pts[i]
    x2, y2 = pts[(i+1) % 4]
    sk.addGeometry(Part.LineSegment(App.Vector(x1, y1, 0), App.Vector(x2, y2, 0)))
for i in range(4):
    sk.addConstraint(Sketcher.Constraint("Coincident", i, 2, (i+1)%4, 1))

pad = doc.addObject("PartDesign::Pad", "BasePad")
body.addObject(pad)
pad.Profile = sk
pad.Length = 25.0
doc.recompute()
```

### Sketch Offset from Origin Plane

Attach sketches to origin planes (XY, XZ, YZ) with Z offset — never reference face names like "Face6" which are fragile and unpredictable.

```python
sk = doc.addObject("Sketcher::SketchObject", "sk_TopProfile")
body.addObject(sk)
sk.AttachmentSupport = [(doc.getObject("XY_Plane"), "")]
sk.MapMode = "FlatFace"
# Offset 25mm above XY plane
sk.AttachmentOffset = App.Placement(App.Vector(0, 0, 25), App.Rotation())
```

### Pocketing (Hollowing, Cuts)

```python
# Pocket sketch on XY_Plane at Z=25 cuts downward into solid
pocket = doc.addObject("PartDesign::Pocket", "HollowPocket")
body.addObject(pocket)
pocket.Profile = sk_hollow
pocket.Length = 23.0  # depth
doc.recompute()

# Through-all pocket
pocket.Type = 1  # 0 = Dimension, 1 = Through All
```

### Ring Profiles (Ledge, Gasket Groove)

Use two concentric rectangles in one sketch to create a ring pocket:

```python
sk = doc.addObject("Sketcher::SketchObject", "sk_Ledge")
body.addObject(sk)
sk.AttachmentSupport = [(doc.getObject("XY_Plane"), "")]
sk.MapMode = "FlatFace"
sk.AttachmentOffset = App.Placement(App.Vector(0, 0, H), App.Rotation())

# Outer rectangle (indices 0-3)
for i in range(4):
    sk.addGeometry(Part.LineSegment(...))  # outer points
for i in range(4):
    sk.addConstraint(Sketcher.Constraint("Coincident", i, 2, (i+1)%4, 1))

# Inner rectangle (indices 4-7)
for i in range(4):
    sk.addGeometry(Part.LineSegment(...))  # inner points
for i in range(4):
    sk.addConstraint(Sketcher.Constraint("Coincident", i+4, 2, ((i+1)%4)+4, 1))
```

### Circles (Bosses, Holes)

```python
sk.addGeometry(Part.Circle(App.Vector(x, y, 0), App.Vector(0, 0, 1), radius))
```

Multiple circles in one sketch → one Pad/Pocket creates all at once.

---

## Datum Planes for Side-Face Features

**This is the most error-prone area. Follow these rules exactly.**

Datum planes let you sketch on side faces without fragile face references. The critical lesson: **attachment offset local Z = the plane's normal direction**, not the world axis you might expect.

### Origin Plane Normals

| Plane | Normal Direction | To reach face at... | Offset |
|-------|-----------------|--------------------|---------|
| XZ_Plane | -Y (!) | Y = -25 | `App.Vector(0, 0, +25)` |
| XZ_Plane | -Y | Y = +25 | `App.Vector(0, 0, -25)` |
| YZ_Plane | -X (!) | X = -50 | `App.Vector(0, 0, +50)` |
| YZ_Plane | -X | X = +50 | `App.Vector(0, 0, -50)` |

**The sign is counterintuitive.** The XZ_Plane normal is -Y, so a positive local-Z offset moves in the -Y direction. Always verify with:

```python
dp = doc.addObject("PartDesign::Plane", "dp_Front")
body.addObject(dp)
dp.AttachmentSupport = [(doc.getObject("XZ_Plane"), "")]
dp.MapMode = "FlatFace"
dp.AttachmentOffset = App.Placement(App.Vector(0, 0, offset), App.Rotation())
doc.recompute()
print("Datum plane at:", dp.Placement.Base)  # Verify world position!
```

If the position is wrong, flip the offset sign.

### Sketch Coordinate Mapping on Datum Planes

| Datum plane derived from | Sketch local X | Sketch local Y |
|--------------------------|---------------|---------------|
| XZ_Plane | World X | World Z |
| YZ_Plane | World Y | World Z |

### Pocket Direction on Datum Planes

When a sketch is on the body's outer surface, the default pocket direction goes **outward** (away from material). You almost always need `Reversed = True` to cut inward:

```python
pocket = doc.addObject("PartDesign::Pocket", "SideVentPocket")
body.addObject(pocket)
pocket.Profile = sk_side
pocket.Length = wall_thickness + 0.1  # through the wall
pocket.Reversed = True  # CUT INWARD, not outward
doc.recompute()
```

### Complete Datum Plane Example (Side Vents)

```python
L, W, wall = 100.0, 50.0, 2.0

# Datum plane at front face (Y = -W/2, centered box)
dp_front = doc.addObject("PartDesign::Plane", "dp_Front")
body.addObject(dp_front)
dp_front.AttachmentSupport = [(doc.getObject("XZ_Plane"), "")]
dp_front.MapMode = "FlatFace"
dp_front.AttachmentOffset = App.Placement(App.Vector(0, 0, W/2), App.Rotation())
doc.recompute()
assert abs(dp_front.Placement.Base.y - (-W/2)) < 0.01, "Datum plane position wrong!"

# Sketch vent slots (local X = world X, local Y = world Z)
sk = doc.addObject("Sketcher::SketchObject", "sk_FrontVents")
body.addObject(sk)
sk.AttachmentSupport = [(dp_front, "")]
sk.MapMode = "FlatFace"

# Draw rectangles for each vent slot...
# (x positions along length, y positions = Z height on wall)

# Pocket inward through wall
pk = doc.addObject("PartDesign::Pocket", "FrontVentPocket")
body.addObject(pk)
pk.Profile = sk
pk.Length = wall + 0.1
pk.Reversed = True  # ALWAYS reverse for surface-mounted datum plane sketches
doc.recompute()

# Hide datum plane
dp_front.ViewObject.Visibility = False
```

---

## Mirrored Features (Symmetry)

Use `PartDesign::Mirrored` to replicate features across a plane. Requires the model to be **centered at the origin** so origin planes serve as mirror planes.

```python
mirror = doc.addObject("PartDesign::Mirrored", "MirrorFrontBack")
body.addObject(mirror)
mirror.Originals = [doc.getObject("FrontVentPocket")]  # note: Originals (plural), list
mirror.MirrorPlane = (doc.getObject("XZ_Plane"), [""])
doc.recompute()
```

| Mirror plane | Mirrors across |
|-------------|---------------|
| XZ_Plane | Front ↔ Back (Y axis) |
| YZ_Plane | Left ↔ Right (X axis) |
| XY_Plane | Top ↔ Bottom (Z axis) |

---

## Multi-Body Assemblies

Use separate bodies for parts that are manufactured separately (e.g., enclosure + cover):

```python
body_enclosure = doc.addObject("PartDesign::Body", "Enclosure")
# ... build enclosure features ...

body_cover = doc.addObject("PartDesign::Body", "Cover")
# ... build cover features ...

# Color them differently for visual distinction
body_cover.ViewObject.ShapeColor = (0.6, 0.7, 0.8)
```

Screw holes in both bodies must use the **same corner coordinates** so they align.

---

## Extracting Colored Sub-Faces for Blender (glTF Placement Gotcha)

When extracting specific colored faces from a Part::Feature (e.g., a logo inlay on a cover, or rubber grommets on a Deutsch connector) to import into Blender as a separate mesh with its own material, you MUST account for the source shape's Placement:

```python
src = doc.getObject("Part__Feature005")   # e.g., DTM4 connector base
faces = src.Shape.Faces                    # POSITIONED faces (src.Placement baked in)
dc = src.ViewObject.DiffuseColor
target_color = (1.0, 0.667, 0.498)
idx = [i for i, c in enumerate(dc) if tuple(round(x,3) for x in c[:3]) == target_color]

comp = Part.makeCompound([faces[i] for i in idx])

# ❌ WRONG — double transform, src.Placement is already baked into comp,
#           and applying link.Placement on top lands the shape at 2× the offset
bad = comp.transformed(link.Placement.toMatrix())

# ✅ RIGHT — un-apply src.Placement first, then apply link.Placement
local = comp.transformed(src.Placement.inverse().toMatrix())
world = local.transformed(link.Placement.toMatrix())

feat = doc.addObject("Part::Feature", "ExtractedFaces")
feat.Shape = world
feat.ViewObject.ShapeColor = target_color
doc.recompute()
ImportGui.export([feat], "/tmp/extract.glb")
doc.removeObject("ExtractedFaces")
```

**Why this matters:** `src.Shape` returns the shape with `src.Placement` already applied. Sources imported from STEP (especially Deutsch connectors) often have non-identity Placements. Symptoms of getting it wrong:
- Extracted mesh lands at Z = +170 mm or −167 mm instead of +11 mm
- The wrong-positioned mesh is often offscreen so you don't notice until the user complains
- Even worse if the source has a rotation — axes get swapped (e.g. local Y becomes world Z)

**Alternative (usually better):** Don't extract at all. If the containing object is already in Blender at the correct world position, add the target material as a second slot on the existing mesh and use the Blender side's `mesh.polygons[i].material_index` to reassign individual faces by their world-space bounding box. See the `/blender-automation` skill for the recipe.

**Blender cannot read `/tmp`** when installed as snap/flatpak. Always `cp` exported files to `$SANDBOX_STAGE_DIR/` before Blender imports them.

---

## View Control (MCP only)

```python
# Standard views
Gui.activeDocument().activeView().viewIsometric()
Gui.activeDocument().activeView().viewFront()
Gui.activeDocument().activeView().viewTop()

# Fit all
Gui.SendMsgToActiveView("ViewFit")
```

---

## Common Operations

**Open and inspect a model:**
```python
doc = App.openDocument("model.FCStd")
for obj in doc.Objects:
    print(f"  {obj.Name} ({obj.TypeId})")
    if hasattr(obj, 'Shape') and obj.Shape and not obj.Shape.isNull():
        print(f"    Volume: {obj.Shape.Volume:.2f} mm³")
        print(f"    BoundBox: {obj.Shape.BoundBox}")
```

**Export to multiple formats:**
```python
import Part as _Part
import Mesh

# STEP (precision CAD exchange)
_Part.export([doc.getObject("Enclosure")], "output.step")

# STL (3D printing)
Mesh.export([doc.getObject("Enclosure")], "output.stl")
```

**Batch export all FCStd files to STEP:**
```python
import FreeCAD, Part, glob, os

input_dir = "Product/TrailCurrentAmpline/CAD/"
output_dir = "Product/TrailCurrentAmpline/CAD/exports/"
os.makedirs(output_dir, exist_ok=True)

for fcstd in glob.glob(os.path.join(input_dir, "*.FCStd")):
    name = os.path.splitext(os.path.basename(fcstd))[0]
    doc = FreeCAD.openDocument(fcstd)
    exportable = [obj for obj in doc.Objects if hasattr(obj, 'Shape')]
    if exportable:
        Part.export(exportable, os.path.join(output_dir, f"{name}.step"))
    FreeCAD.closeDocument(doc.Name)
```

---

## TrailCurrent-Specific Use Cases

- **Product enclosures**: 3D printable cases for ESP32 boards, sensor modules
- **Business cards**: 3D embossed business cards (existing .FCStd and .3mf in Print/BusinessCards/)
- **Mounting brackets**: PCB mo

…(truncated)
