Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,15 @@
"description": "IDE-grade code intelligence for agents — Serena (LSP symbol nav/refactor), ast-grep (AST search/rewrite), and Semgrep (security & dataflow) with usage cheatsheets, setup helpers, and a context-isolated explorer agent",
"name": "agentic-ide",
"source": "./plugins-claude/agentic-ide"
},
{
"author": {
"name": "Logan Gagne"
},
"category": "development",
"description": "Agent-scripted FreeCAD modelling with a live-reload GUI loop — the agent writes the model, the human pans, rotates and steers, the model updates in place with the camera untouched",
"name": "freecad",
"source": "./plugins-claude/freecad"
}
]
}
9 changes: 9 additions & 0 deletions .github/plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,15 @@
"description": "IDE-grade code intelligence for agents — Serena (LSP symbol nav/refactor), ast-grep (AST search/rewrite), and Semgrep (security & dataflow) with usage cheatsheets, setup helpers, and a context-isolated explorer agent",
"name": "agentic-ide",
"source": "./plugins-copilot/agentic-ide"
},
{
"author": {
"name": "Logan Gagne"
},
"category": "development",
"description": "Agent-scripted FreeCAD modelling with a live-reload GUI loop — the agent writes the model, the human pans, rotates and steers, the model updates in place with the camera untouched",
"name": "freecad",
"source": "./plugins-copilot/freecad"
}
]
}
8 changes: 8 additions & 0 deletions plugins-claude/freecad/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "freecad",
"version": "1.0.0",
"description": "Agent-scripted FreeCAD modelling with a live-reload GUI loop — the agent writes the model, the human pans, rotates and steers, the model updates in place with the camera untouched",
"author": {
"name": "Logan Gagne"
}
}
230 changes: 230 additions & 0 deletions plugins-claude/freecad/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
# freecad

Agent-scripted FreeCAD modelling with a live GUI the human steers.

The agent writes the model as a Python script. FreeCAD renders it. The human
pans, rotates, isolates parts, and says what is wrong. The agent edits the
script; the model rebuilds in place with the camera untouched.

It is the OpenSCAD loop — edit, look, edit — but with real B-rep solids, a
draggable assembly, and one tool covering both woodworking and 3D printing.

**The script is the source of truth. The `.FCStd` is a build artefact.** Delete
it whenever you like; it regenerates in seconds. A corrupted document is a
non-event.

## Requirements

FreeCAD 1.0+.

```bash
brew install --cask freecad # macOS
flatpak install org.freecad.FreeCAD # Linux
```

Set `FREECAD_BIN` if it lives somewhere unusual.

## Commands

| Command | Purpose |
|---|---|
| `fcrun model.py` | Headless build: validate + export. No GUI, fast. |
| `fcrun --gui model.py` | Build with the GUI up, so view state is written into the document. |
| `fclive -b model.py` | Live session: rebuilds on every save, preserves the camera. |
| `fcquit model.py` | Stop cleanly. Never `kill` FreeCAD. |
| `fcsnap model.py` | Read the live session back: your camera, your selection, your drags. |
| `fcrender doc.FCStd out/ iso,top,front` | Render PNGs — how the agent sees its own geometry. |

## Skills

| Skill | Invocation |
|---|---|
| `freecad-modeling` | Model-only. Auto-triggers when a FreeCAD model is being designed or edited. |
| `freecad-live` | `/freecad:live [model.py]` — start or stop a live session. |

## Writing a model

```python
import os, sys
sys.path.insert(0, os.environ["WWKIT_LIB"])
import wwkit as ww

ww.UNITS = "in" # reports in inches; geometry is always mm

m = ww.Model("shelf-unit")
side = m.board("Side_Left", "1x10", ww.inch(36),
length_axis="z", thickness_axis="x") # upright
m.dado(side, face="+x", along="y", pos=ww.inch(12),
width=ww.inch("3/4") + 0.4, depth=ww.inch("3/8"))

m.check_clashes() # parts sharing volume will not physically fit
m.envelope() # does the finished thing fit through the door?
m.cutlist() # wood: dimensions for the saw
m.finish(os.environ.get("WW_OUT", os.getcwd()))
```

### Nominal sizes are lies

A "2x4" is 1.5" x 3.5". "3/4" plywood is 23/32". Modelling the nominal number is
the classic way to produce a design that cannot be built, so `ww.LUMBER`,
`ww.PLY`, `ww.MDF` and `ww.BALTIC` hold **actual** dimensions, and `board()` /
`panel()` take the nominal name. Sheet thickness varies by supplier — measure
yours and override rather than trusting the table.

`ww.inch()` accepts `3.5`, `"3/4"`, `"1 1/2"`.

### Joinery

`trench()` is the primitive — a channel cut into a named face, running along a
named axis. `dado()` is that in the middle of a face; `rabbet()` is the same
pushed flush against an edge. Also `mortise()`, `tenon()`, `hole()`, `notch()`.

`kind="wood"` / `kind="printed"` drives colour, the cut list, and the filament
estimate.

Examples: `examples/bracket-box.py` (plywood panels in printed corner brackets,
print clearance parameterised) and `examples/joinery.py` (a shelf unit in real
1x10 with dados, rabbets, and a mortise-and-tenon stretcher).

## Edge selection, and why this dodges FreeCAD's worst bug

`Edge7` gets renumbered by a recompute — that is the topological naming problem,
and it is what breaks mouse-built models. `wwkit` selects edges by *geometry*
(`vertical_edges`, `edges_at`, `edges_where`), which cannot be renumbered.
Script-driven modelling can simply not have FreeCAD's most notorious bug.

## Why not use the Woodworking addon

`dprojects/Woodworking` is real and actively maintained, and we deliberately do
not depend on it: 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. Its
joinery is also thinner than it looks — there is no dado, rabbet, dovetail or
finger-joint generator anywhere in it.

We do stamp the `Woodworking_Width/Height/Length` properties it looks for, so if
you open one of our documents in the GUI with that addon installed, its cut-list
report works. Without the stamp it would silently drop every part, because it
ignores boolean results — which is every part with a joint cut into it.

## From a parts list to a cutting plan

`cutlist()` says what parts you need. `cutplan()` says what to **buy**, and
board by board what to **cut from each one** — the thing you take to the yard
and then to the saw.

```text
CUT PLAN (kerf 3.2 (1/8"))

1x10 -- 1 board(s) of 12 ft
#1: Side_Left (914.4 (36")), Side_Right (914.4 (36")), Shelf_0 (590.5 (23-1/4")), ...
offcut 44.4 (1-3/4")

1/4 sheet -- 1 of 8 ft x 4 ft (18% used, grain respected)
sheet #1:
rip 590.5 (23-1/4") wide: Back (914.4 (36") x 590.5 (23-1/4"))

BUY 1 x 1x10 @ 12 ft; 1 x 2x2 @ 6 ft; 1 x 1/4 sheet
```

Boards are packed by length into stock lengths; sheets are packed into strips,
because that is how you actually cut a sheet — rip it, then crosscut the strips.
Kerf is accounted for in both.

**Grain runs the length of a sheet**, so rotating a part 90° to squeeze it in
turns the grain the wrong way. Rotation is therefore **off by default** — a plan
that saves half a sheet by cross-graining your cabinet sides is not a saving.
Pass `allow_rotate=True` for MDF or when you genuinely do not care.

Packing is first-fit-decreasing, not optimal. Optimal bin packing is NP-hard and
pointless at this scale: with a dozen parts FFD lands within a board of optimal,
and the saw is not that precise anyway.

### Transport, which is a real constraint

A plan that buys a 12ft board is useless if the board will not go in the truck.

```python
m.cutplan(max_length=ww.ft(8), # longest thing you can get home
sheet_piece="half") # have the store cut sheets in half
```

`max_length` removes over-long stock from consideration entirely — the planner
will buy two 8ft boards rather than one 12ft one. A *part* longer than the limit
raises: that is a design problem, not a packing one, since even cut to size it
will not fit in the vehicle.

**You always buy a full 4x8 sheet** — `sheet_piece` is what you ask the store's
panel saw to do to it before it goes in the vehicle:

| `sheet_piece` | piece | grain |
|---|---|---|
| `"full"` | 8ft x 4ft | along the 8ft |
| `"half"` | 4ft x 4ft | along a 4ft axis (crosscut) |
| `"half-rip"` | 8ft x 2ft | still along the 8ft |

A crosscut half and a ripped half have the **same area and different grain**, so
they are not interchangeable. Leave `sheet_piece` unset to let the planner pick
the fewest full sheets, breaking ties toward the smaller piece.

The shopping list says what to ask for:

```text
BUY 2 x 1x10 @ 8 ft; 1 x 2x2 @ 6 ft; 1 x 1/4 full sheet (cut in half at the store, 1 spare)
```

Override what your yard actually stocks:

```python
m.cutplan(stock_lengths=[ww.ft(6), ww.ft(8)], kerf=3.2)
```

### Units: metric shop, imperial lumberyard

`ww.UNITS` is `"mm"` (default), `"in"`, or `"both"`. Geometry is *always* mm;
this only affects reports. `"both"` prints `914.4 (36")` — metric to work in,
imperial to buy in. Stock always keeps its nominal imperial name, so you ask for
a **2x4**, not a 38x89.

## Why this is not OpenCutList

SketchUp's OpenCutList exists to recover dimensions from a model built by hand.
When the model is generated from a script, the script already knows every part's
dimensions — so the cut list and the cut plan fall straight out of the source.

## FreeCAD behaviours this plugin absorbs

Each of these cost a real debugging session.

- **Document names are sanitised.** `newDocument("bracket-box")` produces a doc
named `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. `ww.Model` sanitises.
- **Headless documents open invisible.** No GUI means no view providers, so
`obj.ViewObject` is `None` and every object opens hidden — the document looks
empty. Visibility and colour can only be set with `App.GuiUp`.
- **Under the GUI, `print()` never reaches stdout.** FreeCAD rebinds it to the
Report View. Anything watching the process pipe sees silence forever. Hence
the log files.
- **Non-ASCII output aborts the script**, after printing results but before
exporting — leaving stale artefacts that look current. `ww.say()` enforces
ASCII.
- **`Part::Cut` returns a Compound, not a Solid.** `ShapeType` says nothing
about printability; the mesh does (closed, manifold, no self-intersections).
- **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. `fcquit` closes documents via the API (which does not
prompt) and then the window.
- **`open -a FreeCAD` starts a second instance.** We launch the binary directly
so it inherits the environment, which means macOS never registers it with
LaunchServices — so `open -a` cannot find it and launches another. AppleScript
cannot address it either.
- **Camera moves are animated.** Capture straight after a view change and you
photograph the camera mid-flight; `fcrender` settles the event loop first.

## Trackpad navigation

FreeCAD defaults to three-button-mouse bindings that are unusable on a laptop.
Set the navigation style to **Gesture** in the status-bar dropdown (bottom
right, shows `CAD` by default): left-drag rotates, two-finger drag pans, scroll
zooms. Spacebar toggles visibility of the tree selection — that is how you
isolate a part.
60 changes: 60 additions & 0 deletions plugins-claude/freecad/examples/bracket-box.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Mixed-material reference model: plywood panels held by printed corners.

Run it:
fcrun examples/bracket-box.py # validate + export, no GUI
fclive -b examples/bracket-box.py # watch it, rebuild as you edit
fcsnap examples/bracket-box.py # read the live session back
fcrender out/bracket_box.FCStd # PNGs

Every number worth arguing about is at the top.
"""

import os
import sys

sys.path.insert(0, os.environ["WWKIT_LIB"])
import wwkit as ww # noqa: E402

# --- parameters ---------------------------------------------------------
W, D, H = 300.0, 200.0, 150.0 # outer envelope
SHEET = "3/4" # plywood, actual thickness from ww.PLY
CLEAR = 0.4 # total slot clearance over the ply (0.2/side)
POST = 30.0 # bracket cross-section
BH = 50.0 # bracket height (two per corner)
GROOVE = 8.0 # depth a panel seats into a bracket
EDGE = 6.0 # bracket setback from top/bottom

PLY = ww.PLY[SHEET] # 23/32", not 3/4" — nominal sizes are lies
SLOT = PLY + CLEAR
OUT = os.environ.get("WW_OUT", os.getcwd())

m = ww.Model("bracket-box")

# --- printed corner bracket ---------------------------------------------
# Two perpendicular grooves, opening toward the box interior.
bracket = ww.solid(POST, POST, BH)
bracket = bracket.cut(ww.solid(GROOVE, SLOT, BH, at=(POST - GROOVE, 0, 0)))
bracket = bracket.cut(ww.solid(SLOT, GROOVE, BH, at=(0, POST - GROOVE, 0)))

m.check_printable(bracket, "bracket")

for i, ((cx, cy), rot) in enumerate(
[((0, 0), 0), ((W, 0), 90), ((W, D), 180), ((0, D), 270)]
):
for tag, z in (("lo", EDGE), ("hi", H - EDGE - BH)):
m.place("Bracket_%d%s" % (i, tag), bracket, at=(cx, cy, z), rot_z=rot)

# --- plywood panels ------------------------------------------------------
# Each spans between brackets and seats GROOVE deep into each.
inset = POST - GROOVE
m.box("Panel_Front", W - 2 * inset, PLY, H, at=(inset, 0, 0))
m.box("Panel_Back", W - 2 * inset, PLY, H, at=(inset, D - PLY, 0))
m.box("Panel_Left", PLY, D - 2 * inset, H, at=(0, inset, 0))
m.box("Panel_Right", PLY, D - 2 * inset, H, at=(W - PLY, inset, 0))

# --- report + write ------------------------------------------------------
m.check_clashes()
m.envelope()
m.cutlist()
m.filament()
m.finish(OUT)
Loading
Loading