Skip to content

feat: add freecad plugin for agent-scripted CAD with a live GUI loop#159

Merged
st0nefish-ci[bot] merged 1 commit into
masterfrom
feat/freecad-plugin
Jul 13, 2026
Merged

feat: add freecad plugin for agent-scripted CAD with a live GUI loop#159
st0nefish-ci[bot] merged 1 commit into
masterfrom
feat/freecad-plugin

Conversation

@St0nefish

Copy link
Copy Markdown
Owner

What

A plugin for designing in FreeCAD the way an agent and a human can actually share the work: the agent authors the model as a Python script, FreeCAD renders it, and the human pans, rotates, isolates parts, and drags things around. The script is the source of truth; the .FCStd is a disposable build artefact.

One tool covering both woodworking and 3D printing — which is why it isn't SketchUp (surface modeller, no true solids, notorious for non-manifold STLs) or Blender (mesh modeller, no constraints, nothing enforcing that a printed bracket fits a wooden slot).

The loop runs both ways

The interesting part is fcsnap. Without it the agent only pushes geometry, and the human's manipulation is invisible to it and destroyed by the next rebuild. fcsnap returns the user's actual camera (not a canned view), what they have selected (so "this one" resolves to a name), and moved — how each part's placement now differs from where the script put it.

That turns a drag into a proposal the agent folds back into the source, instead of something the next rebuild silently eats.

Contents

fcrun headless build: validation + STL/STEP/FCStd export
fclive live session — rebuilds on save, preserves the camera
fcquit clean shutdown (never kill FreeCAD; see below)
fcsnap read the session back: camera, selection, drags
fcrender PNGs, so the agent checks its own geometry before spending the user's attention
  • lib/wwkit — parametric modelling; real dimensional stock (a 2x4 is 1.5x3.5", "3/4" ply is 23/32"); joinery (trench/dado/rabbet/mortise/tenon/hole/fillet/chamfer); clash + printability checks; cut list. Units are mm / in / both — metric shop, imperial lumberyard.
  • lib/wwcut — a cut plan, not just a cut list: packs parts into stock boards and sheets with kerf, honours a transport limit (a 12ft board is a useless plan if it won't go in the truck), and says what to buy. Grain is respected: rotation is off by default, because a plan that saves half a sheet by cross-graining your cabinet sides is not a saving.
  • skillsfreecad-modeling (model-only guide), freecad-live (/freecad:live)
  • examples — mixed wood-and-printed bracket box; a shelf unit in real 1x10 with housed shelves, a rabbeted ply back, and a mortise-and-tenon stretcher

Two design decisions worth flagging

Edges are selected by geometric predicate, never by index. Edge7 gets renumbered by a recompute — that is FreeCAD's topological naming problem, and it's what breaks mouse-built models. A predicate can't be renumbered, so these models are structurally immune to it.

Deliberately does not depend on dprojects/Woodworking. Every one of its tools is bound to FreeCADGui.Selection or executes Qt dialog code at import time, so none of it is callable headless — it would break the entire loop. Its joinery is also thinner than it looks (no dado, rabbet, dovetail, or finger-joint generator anywhere in it). We do stamp the Woodworking_Width/Height/Length properties it looks for, so its cut-list report works on our documents if a user has it installed — without the stamp it silently drops every part, since it ignores boolean results.

FreeCAD behaviours absorbed (each cost a real debugging session)

  • Document names are sanitisednewDocument("bracket-box") yields bracket_box. Look it up by the original name and you miss, so a live-reload loop stacks bracket_box, bracket_box1, ... forever instead of replacing.
  • Headless documents open invisible — no GUI means no view providers, so everything opens hidden and the file looks empty.
  • Under the GUI, print() never reaches stdout — FreeCAD rebinds it to the Report View, so anything watching the process pipe sees silence forever. Hence the log files.
  • Non-ASCII output aborts the script — after printing results, before exporting, leaving stale artefacts that look current.
  • -c does not exit — it drops into an interactive console awaiting EOF, leaking a FreeCAD process indefinitely.
  • Closing the window is not quitting — with no documents left, Qt's event loop keeps running and the process lingers.
  • Never hard-kill FreeCAD — it leaves recovery breadcrumbs and the next launch hangs behind a modal "Original file corrupted" dialog, invisibly, in an automated run.

Testing

Developed and verified end-to-end on macOS (Apple Silicon, FreeCAD 1.1.1): headless build, live reload with camera preservation, snapshot/drag-diff, clean shutdown, offscreen render.

A read-only Linux portability audit was run and its findings applied — mktemp -t (fails on GNU), script-path absolutisation (Flatpak doesn't carry the caller's cwd into the sandbox), broadened process detection (name varies across distros/AppImage/Flatpak), an xvfb-run path for headless boxes, PySide2/PySide6 fallback imports, and a Flatpak --filesystem=host preflight warning.

Not yet exercised on real Linux hardware — that's the known gap.

🤖 Generated with Claude Code

Adds a plugin for designing in FreeCAD the way an agent and a human can
actually share: the agent authors the model as a Python script, FreeCAD
renders it, and the human pans, rotates, isolates parts and drags things
around. The script is the source of truth; the .FCStd is a disposable
build artefact. It covers woodworking and 3D printing in one tool, which
SketchUp (surface modeller, no real solids) and Blender (mesh modeller,
no constraints) cannot.

The loop runs both ways. fcsnap reports the user's actual camera, their
selection, and how each part's placement now differs from where the
script put it — so a drag becomes a proposal to fold back into the
source rather than something the next rebuild silently destroys.

- scripts: fcrun (headless build + validation + exports), fclive (live
  session, rebuilds on save, preserves the camera), fcquit (clean
  shutdown), fcsnap (read the session back), fcrender (PNGs, so the
  agent can check its own geometry before spending the user's attention)
- lib/wwkit: parametric modelling, real dimensional stock (a 2x4 is
  1.5x3.5", "3/4" ply is 23/32"), joinery (trench/dado/rabbet/mortise/
  tenon/hole/fillet/chamfer), clash + printability checks, cut list
- lib/wwcut: cut *plan* — packs parts into stock boards and sheets with
  kerf, honours a transport limit, and reports what to buy
- skills: freecad-modeling (model-only guide), freecad-live (/freecad:live)
- examples: mixed wood-and-printed bracket box; shelf unit in real 1x10
  with housed shelves, a rabbeted back, and a mortise-and-tenon stretcher

Edges are selected by geometric predicate rather than by index, which
makes these models structurally immune to FreeCAD's topological naming
problem.

Deliberately does not depend on dprojects/Woodworking: every one of its
tools is bound to FreeCADGui.Selection or runs Qt dialog code at import
time, so none of it is callable headless. We do stamp the
Woodworking_Width/Height/Length properties it looks for, so its report
tool works on our documents if a user has it installed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 13, 2026 18:07
@St0nefish St0nefish self-assigned this Jul 13, 2026
@st0nefish-ci
st0nefish-ci Bot enabled auto-merge July 13, 2026 18:07
@st0nefish-ci
st0nefish-ci Bot merged commit eb4abe3 into master Jul 13, 2026
7 checks passed
@st0nefish-ci
st0nefish-ci Bot deleted the feat/freecad-plugin branch July 13, 2026 18:10

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new FreeCAD plugin that enables an “agent-authored Python script ↔ human-steered GUI” modeling loop, including headless builds/exports, live-reload sessions with camera preservation, session snapshots (camera/selection/drags), and geometry/render helpers for woodworking + 3D printing workflows.

Changes:

  • Introduces FreeCAD plugin packaging for both Claude and Copilot marketplaces (metadata + hooks registration).
  • Adds core command scripts (fcrun, fclive, fcquit, fcsnap, fcrender) plus a live-reload watcher and renderer.
  • Adds modeling libraries (wwkit, wwcut) and examples/skills documentation for the new workflow.

Reviewed changes

Copilot reviewed 29 out of 29 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
utils/sync.sh Adds freecad to the vendored-utils sync manifest (approve/hook-compat).
plugins-copilot/freecad/hooks/hooks.json Registers the Copilot preToolUse hook to run approve-own-scripts.sh.
plugins-copilot/freecad/.claude-plugin/plugin.json Copilot plugin metadata for freecad.
plugins-claude/freecad/skills/modeling/SKILL.md “Modeling” skill guidance and loop conventions (script as source of truth, etc.).
plugins-claude/freecad/skills/live/SKILL.md “Live session” skill guidance for starting/stopping and iterating.
plugins-claude/freecad/scripts/hook-compat.sh Normalizes hook payloads across Claude Code and Copilot CLI.
plugins-claude/freecad/scripts/fcsnap CLI entrypoint to request a snapshot from the live session and emit JSON.
plugins-claude/freecad/scripts/fcsession Shared helpers for per-model control file paths and process detection.
plugins-claude/freecad/scripts/fcrun Headless/GUI FreeCAD runner with output filtering and error detection.
plugins-claude/freecad/scripts/fcrender Uses GUI FreeCAD to render .FCStd to PNGs via render.py.
plugins-claude/freecad/scripts/fcquit Clean shutdown via watcher stop-file (avoids hard-kill corruption dialogs).
plugins-claude/freecad/scripts/fclive Launches the GUI live-reload watcher (foreground or background).
plugins-claude/freecad/scripts/approve-own-scripts.sh Hook script that auto-approves plugin-local scripts and guards PR publish/merge.
plugins-claude/freecad/README.md End-user documentation for requirements, commands, and workflow rationale.
plugins-claude/freecad/lib/wwkit.py FreeCAD modeling toolkit (parts, joinery, checks, reports, export, intent stamping).
plugins-claude/freecad/lib/wwcut.py Cut-plan packing logic for boards and sheet goods (transport constraints, kerf, grain rules).
plugins-claude/freecad/lib/render.py GUI-driven renderer (views, settle timing, watchdog, logging).
plugins-claude/freecad/lib/live-reload.py GUI watcher that rebuilds on save, preserves camera, supports “snap” payloads.
plugins-claude/freecad/hooks/hooks.json Registers Claude Code hook to auto-approve own scripts for Bash tool use.
plugins-claude/freecad/examples/joinery.py Example model demonstrating real lumber sizes + joinery + cut planning.
plugins-claude/freecad/examples/bracket-box.py Example mixed wood/printed model demonstrating printable parts + intent.
plugins-claude/freecad/.claude-plugin/plugin.json Claude plugin metadata for freecad.
.github/plugin/marketplace.json Adds freecad to the GitHub marketplace manifest (Copilot source path).
.claude-plugin/marketplace.json Adds freecad to the Claude marketplace manifest (Claude source path).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +141 to +153
filter() {
# --line-buffered for the same reason as PYTHONUNBUFFERED above: grep would
# otherwise hold the watcher's output hostage in a 4K block.
grep --line-buffered -avE \
-e '^[A-Za-z_][A-Za-z0-9_]*=' \
-e '3Dconnexion|Navlib' \
-e '^Running: ' \
-e '\([0-9]+ %\)' \
-e '^(Recompute|Checking topology|Checking for self-intersections|saving)\.{3,}' \
-e '^\[FreeCAD Console mode' \
-e '^>>> *$' \
-e '^[[:space:]]*$'
}
Comment on lines +32 to +45
# The GUI swallows stdout into its Report View, so the log file is how we find
# out what happened. Errors there are otherwise completely invisible.
FCRENDER_DOC="$(cd "$(dirname "$DOC")" && pwd)/$(basename "$DOC")" \
FCRENDER_OUT="$(cd "$OUT" && pwd)" \
FCRENDER_LOG="$LOG" \
FCRENDER_VIEWS="$VIEWS" \
FCRENDER_W="${FCRENDER_W:-1200}" \
FCRENDER_H="${FCRENDER_H:-900}" \
"$HERE/fcrun" --gui "$LIB_DIR/render.py" >/dev/null 2>&1 || true

cat "$LOG"
if grep -qa "RENDER FAILED" "$LOG"; then
exit 1
fi
Comment on lines +30 to +33
if ! _fc_running; then
echo "fcsnap: no live session — start one with: fclive -b $(basename "$SCRIPT")" >&2
exit 3
fi
Comment on lines +468 to +474
def gap(self, a, b):
"""Shortest distance between two parts. Negative means they overlap."""
d = a.shape.distToShape(b.shape)[0]
if a.shape.common(b.shape).Volume > 1e-6:
d = -d
say("GAP %s <-> %s %s mm" % (a.name, b.name, fmt(d)))
return d
Comment on lines +598 to +606
def filament(self, density=PLA_DENSITY):
printed = self.of_kind("printed")
if not printed:
return 0.0
grams = sum(p.shape.Volume / 1000.0 * density for p in printed)
each = printed[0].shape.Volume / 1000.0 * density
say("FILAMENT %d part(s), %.1f g each, %.0f g total (%.0f%% of a 1kg spool)"
% (len(printed), each, grams, grams / 10.0))
return grams
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants