From 5582a6d0f9f829fbce865b7833519a1d65ff2fe4 Mon Sep 17 00:00:00 2001 From: Logan Gagne Date: Mon, 13 Jul 2026 14:01:40 -0400 Subject: [PATCH] feat: add freecad plugin for agent-scripted CAD with a live GUI loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .claude-plugin/marketplace.json | 9 + .github/plugin/marketplace.json | 9 + .../freecad/.claude-plugin/plugin.json | 8 + plugins-claude/freecad/README.md | 230 ++++++ .../freecad/examples/bracket-box.py | 60 ++ plugins-claude/freecad/examples/joinery.py | 94 +++ plugins-claude/freecad/hooks/hooks.json | 16 + plugins-claude/freecad/lib/live-reload.py | 240 ++++++ plugins-claude/freecad/lib/render.py | 157 ++++ plugins-claude/freecad/lib/wwcut.py | 259 +++++++ plugins-claude/freecad/lib/wwkit.py | 722 ++++++++++++++++++ .../freecad/scripts/approve-own-scripts.sh | 142 ++++ plugins-claude/freecad/scripts/fclive | 58 ++ plugins-claude/freecad/scripts/fcquit | 72 ++ plugins-claude/freecad/scripts/fcrender | 45 ++ plugins-claude/freecad/scripts/fcrun | 177 +++++ plugins-claude/freecad/scripts/fcsession | 36 + plugins-claude/freecad/scripts/fcsnap | 56 ++ plugins-claude/freecad/scripts/hook-compat.sh | 105 +++ plugins-claude/freecad/skills/live/SKILL.md | 63 ++ .../freecad/skills/modeling/SKILL.md | 169 ++++ .../freecad/.claude-plugin/plugin.json | 8 + plugins-copilot/freecad/README.md | 1 + plugins-copilot/freecad/examples | 1 + plugins-copilot/freecad/hooks/hooks.json | 11 + plugins-copilot/freecad/lib | 1 + plugins-copilot/freecad/scripts | 1 + plugins-copilot/freecad/skills | 1 + utils/sync.sh | 1 + 29 files changed, 2752 insertions(+) create mode 100644 plugins-claude/freecad/.claude-plugin/plugin.json create mode 100644 plugins-claude/freecad/README.md create mode 100644 plugins-claude/freecad/examples/bracket-box.py create mode 100644 plugins-claude/freecad/examples/joinery.py create mode 100644 plugins-claude/freecad/hooks/hooks.json create mode 100644 plugins-claude/freecad/lib/live-reload.py create mode 100644 plugins-claude/freecad/lib/render.py create mode 100644 plugins-claude/freecad/lib/wwcut.py create mode 100644 plugins-claude/freecad/lib/wwkit.py create mode 100755 plugins-claude/freecad/scripts/approve-own-scripts.sh create mode 100755 plugins-claude/freecad/scripts/fclive create mode 100755 plugins-claude/freecad/scripts/fcquit create mode 100755 plugins-claude/freecad/scripts/fcrender create mode 100755 plugins-claude/freecad/scripts/fcrun create mode 100755 plugins-claude/freecad/scripts/fcsession create mode 100755 plugins-claude/freecad/scripts/fcsnap create mode 100644 plugins-claude/freecad/scripts/hook-compat.sh create mode 100644 plugins-claude/freecad/skills/live/SKILL.md create mode 100644 plugins-claude/freecad/skills/modeling/SKILL.md create mode 100644 plugins-copilot/freecad/.claude-plugin/plugin.json create mode 120000 plugins-copilot/freecad/README.md create mode 120000 plugins-copilot/freecad/examples create mode 100644 plugins-copilot/freecad/hooks/hooks.json create mode 120000 plugins-copilot/freecad/lib create mode 120000 plugins-copilot/freecad/scripts create mode 120000 plugins-copilot/freecad/skills diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6d2d89e..0ee0d55 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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" } ] } diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index a055879..20fee7e 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -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" } ] } diff --git a/plugins-claude/freecad/.claude-plugin/plugin.json b/plugins-claude/freecad/.claude-plugin/plugin.json new file mode 100644 index 0000000..048b699 --- /dev/null +++ b/plugins-claude/freecad/.claude-plugin/plugin.json @@ -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" + } +} diff --git a/plugins-claude/freecad/README.md b/plugins-claude/freecad/README.md new file mode 100644 index 0000000..fa7fa5b --- /dev/null +++ b/plugins-claude/freecad/README.md @@ -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. diff --git a/plugins-claude/freecad/examples/bracket-box.py b/plugins-claude/freecad/examples/bracket-box.py new file mode 100644 index 0000000..e00b9a9 --- /dev/null +++ b/plugins-claude/freecad/examples/bracket-box.py @@ -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) diff --git a/plugins-claude/freecad/examples/joinery.py b/plugins-claude/freecad/examples/joinery.py new file mode 100644 index 0000000..aa67cf4 --- /dev/null +++ b/plugins-claude/freecad/examples/joinery.py @@ -0,0 +1,94 @@ +"""Joinery reference: real lumber, real joints, imperial input. + +A small shelf unit. Two 1x10 sides stand upright, dado-ed on their inner faces +to take three 1x10 shelves, with a rabbet down each inner back edge for a ply +back, and a 2x2 stretcher mortised and tenoned into the sides at the top. + +The point of this file is that none of the numbers are nominal. A "1x10" is +3/4" x 9-1/4"; "3/4" ply is 23/32". Model the nominal sizes and you get a design +that will not go together — and `check_clashes()` will tell you so. + + fcrun examples/joinery.py + fclive -b examples/joinery.py +""" + +import os +import sys + +sys.path.insert(0, os.environ["WWKIT_LIB"]) +import wwkit as ww # noqa: E402 + +ww.UNITS = "both" # metric shop, imperial lumberyard + +# --- parameters --------------------------------------------------------- +STOCK = "1x10" +HEIGHT = ww.inch(36) +WIDTH = ww.inch(24) # outside to outside +SHELVES = 3 +BACK = "1/4" # ply thickness key +FIT = 0.4 # dado cut wider than the shelf, so it seats +TENON = ww.inch("1/2") # how far the stretcher tenon enters the side + +T, DEPTH = ww.LUMBER[STOCK] # 3/4" thick, 9-1/4" wide +BACK_T = ww.PLY[BACK] +DADO = T / 2.0 # half the stock thickness, the usual rule +OUT = os.environ.get("WW_OUT", os.getcwd()) + +m = ww.Model("shelf-unit") + +# --- sides: upright, thickness across X, depth along Y, height up Z ------ +left = m.board("Side_Left", STOCK, HEIGHT, at=(0, 0, 0), + length_axis="z", thickness_axis="x") +right = m.board("Side_Right", STOCK, HEIGHT, at=(WIDTH - T, 0, 0), + length_axis="z", thickness_axis="x") + +# Inner faces look at each other. +INNER = {"Side_Left": "+x", "Side_Right": "-x"} + +# --- shelves, housed in dados ------------------------------------------- +spacing = HEIGHT / (SHELVES + 1) +heights = [spacing * (i + 1) for i in range(SHELVES)] +span = WIDTH - 2 * T + 2 * DADO # reaches DADO deep into each side + +for side in (left, right): + for z in heights: + m.dado(side, face=INNER[side.name], along="y", + pos=z, width=T + FIT, depth=DADO) + # Rabbet down the inner back edge to receive the ply back. + m.rabbet(side, face=INNER[side.name], edge="+y", + width=BACK_T, depth=DADO) + +# Shelves are ripped narrower so they stop in front of the back panel. +SHELF_DEPTH = DEPTH - BACK_T +for i, z in enumerate(heights): + m.board("Shelf_%d" % i, STOCK, span, at=(T - DADO, 0, z), + length_axis="x", thickness_axis="z", rip=SHELF_DEPTH) + +# --- back panel, sitting in the rabbets --------------------------------- +# Thickness on Y, not Z: a back panel is thin front-to-back. +m.panel("Back", BACK, span, HEIGHT, at=(T - DADO, DEPTH - BACK_T, 0), + thickness_axis="y") + +# --- stretcher: tenoned into mortises in the sides ----------------------- +TW, TT = ww.inch(1), ww.inch(1) # tongue section, centred in the 2x2 +st_len = WIDTH - 2 * T + 2 * TENON +st_z = HEIGHT - ww.inch(3) +stretcher = m.board("Stretcher", "2x2", st_len, at=(T - TENON, 0, st_z), + length_axis="x", thickness_axis="z") +m.tenon(stretcher, end="-x", size=(TW, TT), length=TENON) +m.tenon(stretcher, end="+x", size=(TW, TT), length=TENON) + +sb = stretcher.bbox +for side in (left, right): + m.mortise(side, face=INNER[side.name], + at=(sb.YMin + (ww.LUMBER["2x2"][1] - TW) / 2.0, sb.ZMin + (ww.LUMBER["2x2"][0] - TT) / 2.0), + size=(TW, TT), depth=TENON) + +# --- report + write ------------------------------------------------------ +m.check_clashes() +m.envelope() +m.cutlist() +# Transport: a Tacoma bed with the tailgate down takes about 8ft, no more. +# Sheet goods come home as halves — the store cuts them. +m.cutplan(max_length=ww.ft(8), sheet_piece="half") +m.finish(OUT) diff --git a/plugins-claude/freecad/hooks/hooks.json b/plugins-claude/freecad/hooks/hooks.json new file mode 100644 index 0000000..612f91a --- /dev/null +++ b/plugins-claude/freecad/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "description": "Auto-approve this plugin's own scripts", + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/approve-own-scripts.sh" + } + ] + } + ] + } +} diff --git a/plugins-claude/freecad/lib/live-reload.py b/plugins-claude/freecad/lib/live-reload.py new file mode 100644 index 0000000..8295ebe --- /dev/null +++ b/plugins-claude/freecad/lib/live-reload.py @@ -0,0 +1,240 @@ +"""Watch a model script and rebuild it in the open FreeCAD window on change. + +The point is that the human never closes the file. They pan, rotate, isolate; +the agent edits the script; the model updates underneath them with the camera +where they left it. That is the loop OpenSCAD gets right and stock FreeCAD +does not. + +Camera is captured before the rebuild and restored after, because the rebuild +closes and recreates the document (wwkit.Model does this by name, which is +what stops FreeCAD from silently stacking up model001, model002, ...). + +A script that raises leaves the previous model on screen and prints the +traceback — a typo shouldn't blank the window mid-conversation. +""" + +import json +import os +import sys +import traceback + +import FreeCAD as App +import FreeCADGui as Gui +# FreeCAD bundles a `PySide` shim re-exporting whatever binding it was built +# against (PySide2/Qt5 or PySide6/Qt6). That shim is reported broken on some +# Linux distro packages, and an unguarded import here would take the whole tool +# down with an ImportError. Fall through to the real bindings. +try: + from PySide import QtCore +except ImportError: # pragma: no cover - distro-dependent + try: + from PySide6 import QtCore + except ImportError: + from PySide2 import QtCore + +SCRIPT = os.environ["WW_WATCH"] +POLL_MS = int(os.environ.get("WW_POLL_MS", "1000")) +LOG = os.environ.get("WW_LOG") +STOP = os.environ.get("WW_STOP") +SNAP = os.environ.get("WW_SNAP") + +_state = {"mtime": 0.0, "camera": None, "builds": 0} + + +def _say(msg): + """Under the GUI, print() goes to FreeCAD's Report View, not to stdout. + Anyone watching the process pipe sees silence — so write the file too.""" + print(msg) + if LOG: + try: + with open(LOG, "a") as fh: + fh.write(str(msg) + "\n") + except OSError: + pass + + +def _view(): + doc = Gui.activeDocument() + return doc.activeView() if doc else None + + +def _rebuild(): + view = _view() + if view is not None: + try: + _state["camera"] = view.getCamera() + except Exception: + pass + + # Drop wwkit from the module cache so edits to the library take effect too, + # not just edits to the model script. Without this, `import wwkit` is a + # no-op after the first build and library changes look silently ignored. + sys.modules.pop("wwkit", None) + + try: + source = open(SCRIPT).read() + globals_ = {"__name__": "__main__", "__file__": SCRIPT} + exec(compile(source, SCRIPT, "exec"), globals_) + except Exception: + _say("LIVE rebuild FAILED - previous model left on screen") + _say(traceback.format_exc()) + return False + + view = _view() + if view is not None: + if _state["camera"] and _state["builds"] > 0: + try: + view.setCamera(_state["camera"]) + except Exception: + Gui.SendMsgToActiveView("ViewFit") + else: + view.viewAxonometric() + Gui.SendMsgToActiveView("ViewFit") + _state["builds"] += 1 + return True + + +def _quit(): + """Shut down without the modal save prompt. + + Closing the main window asks 'save changes?' and blocks forever in an + automated run. App.closeDocument() discards via the API without asking, so + close the documents first and the window then has nothing to ask about. + Discarding is safe by construction: the script is the source of truth and + the .FCStd is a build artefact. + """ + _say("LIVE stop requested, closing cleanly") + for name in list(App.listDocuments()): + try: + App.closeDocument(name) + except Exception: + pass + try: + os.remove(STOP) + except OSError: + pass + + # Closing the main window is not the same as quitting: with no documents + # left, Qt's event loop happily keeps running and the process lingers (this + # is normal macOS app behaviour). Ask the application itself to exit, or + # fcquit times out and the only way out is a kill — which is exactly what + # poisons the next launch. + try: + Gui.getMainWindow().close() + except Exception: + pass + QtCore.QCoreApplication.quit() + + +def _snapshot(out_dir): + """Report the live session back to the agent: view, selection, drags. + + Without this the loop only runs one way — the agent pushes geometry and the + human's manipulation is invisible and destroyed by the next rebuild. Here we + capture: + + * a PNG of the user's *actual* camera (not a canned view) + * what they have selected, so "this one" resolves to a name + * how each part's placement differs from where the script put it + + That last one turns a drag into a proposal the agent can fold back into the + script, rather than something the next rebuild silently throws away. + """ + result = {"png": None, "selected": [], "moved": [], "doc": None} + try: + gdoc = Gui.activeDocument() + if gdoc is None: + result["error"] = "no active document" + return result + doc = App.activeDocument() + result["doc"] = doc.Name + + view = gdoc.activeView() + png = os.path.join(out_dir, "%s.snap.png" % doc.Name) + view.saveImage(png, 1400, 1000, "Current") + result["png"] = png + + try: + result["selected"] = [o.Name for o in Gui.Selection.getSelection()] + except Exception: + pass + + intent_path = getattr(App, "__ww_intent__", None) + if intent_path and os.path.exists(intent_path): + with open(intent_path) as fh: + intent = json.load(fh) + for name, want in intent.get("parts", {}).items(): + obj = doc.getObject(name) + if obj is None: + continue + now = obj.Placement.Base + dx = now.x - want["pos"][0] + dy = now.y - want["pos"][1] + dz = now.z - want["pos"][2] + dyaw = obj.Placement.Rotation.toEuler()[0] - want.get("yaw", 0.0) + if max(abs(dx), abs(dy), abs(dz), abs(dyaw)) > 1e-6: + result["moved"].append( + { + "name": name, + "kind": want.get("kind"), + "delta_mm": [round(dx, 2), round(dy, 2), round(dz, 2)], + "delta_yaw_deg": round(dyaw, 2), + } + ) + else: + result["note"] = "no intent file - drags cannot be diffed" + except Exception: + result["error"] = traceback.format_exc() + return result + + +def _handle_snap(): + try: + with open(SNAP) as fh: + out_dir = fh.read().strip() + except OSError: + out_dir = os.path.dirname(SCRIPT) + if not out_dir: + out_dir = os.path.dirname(SCRIPT) + + result = _snapshot(out_dir) + try: + with open(os.path.join(out_dir, "snapshot.json"), "w") as fh: + json.dump(result, fh, indent=2) + except OSError: + pass + try: + os.remove(SNAP) + except OSError: + pass + _say("SNAP %d moved, %d selected -> %s" + % (len(result["moved"]), len(result["selected"]), result.get("png"))) + + +def _tick(): + if STOP and os.path.exists(STOP): + _quit() + return + + if SNAP and os.path.exists(SNAP): + _handle_snap() + + try: + mtime = os.path.getmtime(SCRIPT) + except OSError: + return # mid-write; try again next tick + if mtime <= _state["mtime"]: + return + _state["mtime"] = mtime + _say("LIVE change detected, rebuilding %s" % os.path.basename(SCRIPT)) + if _rebuild(): + _say("LIVE rebuild #%d ok" % _state["builds"]) + + +_timer = QtCore.QTimer() +_timer.timeout.connect(_tick) +_timer.start(POLL_MS) +App.__ww_live_timer = _timer # keep a reference or Qt garbage-collects it + +_say("LIVE watching %s (every %dms)" % (SCRIPT, POLL_MS)) +_tick() diff --git a/plugins-claude/freecad/lib/render.py b/plugins-claude/freecad/lib/render.py new file mode 100644 index 0000000..54b11d9 --- /dev/null +++ b/plugins-claude/freecad/lib/render.py @@ -0,0 +1,157 @@ +"""Render an .FCStd to PNGs, then quit. Driven by fcrender via env vars. + +Runs under the FreeCAD GUI because image capture needs a real view provider and +a GL context — there is no headless render path. + +Two hard-won constraints shape this file: + +* Under the GUI, FreeCAD rebinds Python's stdout to its Report View, so print() + never reaches the calling process. Anything the caller must see goes to + $FCRENDER_LOG. +* If capture() raises, the exception surfaces only in that Report View and the + window sits open forever — an invisible hang. So capture() can never be + allowed to leave the window open: it closes in a finally, and a watchdog + closes it regardless if capture never returns at all. + +A headless-authored document opens with every object hidden (no view providers +were ever created), so anything not explicitly shown renders as an empty frame. +""" + +import os +import traceback + +import FreeCAD as App +import FreeCADGui as Gui +# FreeCAD bundles a `PySide` shim re-exporting whatever binding it was built +# against (PySide2/Qt5 or PySide6/Qt6). That shim is reported broken on some +# Linux distro packages, and an unguarded import here would take the whole tool +# down with an ImportError. Fall through to the real bindings. +try: + from PySide import QtCore +except ImportError: # pragma: no cover - distro-dependent + try: + from PySide6 import QtCore + except ImportError: + from PySide2 import QtCore + +DOC = os.environ["FCRENDER_DOC"] +OUT = os.environ["FCRENDER_OUT"] +LOG = os.environ.get("FCRENDER_LOG") +VIEWS = os.environ.get("FCRENDER_VIEWS", "iso,front,top,right").split(",") +W = int(os.environ.get("FCRENDER_W", "1200")) +H = int(os.environ.get("FCRENDER_H", "900")) +WATCHDOG_MS = int(os.environ.get("FCRENDER_WATCHDOG_MS", "45000")) + +SETTERS = { + "iso": "viewAxonometric", + "axo": "viewAxonometric", + "front": "viewFront", + "rear": "viewRear", + "top": "viewTop", + "bottom": "viewBottom", + "left": "viewLeft", + "right": "viewRight", +} + +SETTLE_MS = int(os.environ.get("FCRENDER_SETTLE_MS", "700")) + +_done = {"finished": False} + + +def _settle(ms): + """Pump the event loop so a camera animation can finish before capture.""" + deadline = QtCore.QElapsedTimer() + deadline.start() + while deadline.elapsed() < ms: + QtCore.QCoreApplication.processEvents( + QtCore.QEventLoop.AllEvents, 50 + ) + + +def say(msg): + print(msg) + if LOG: + try: + with open(LOG, "a") as fh: + fh.write(str(msg) + "\n") + except OSError: + pass + + +def shutdown(): + """Close documents via the API (silent) so the window has nothing to ask + about, then close the window. Never leaves a live instance behind.""" + for name in list(App.listDocuments()): + try: + App.closeDocument(name) + except Exception: + pass + try: + Gui.getMainWindow().close() + except Exception: + pass + # Closing the window leaves the event loop running; quit the app outright. + QtCore.QCoreApplication.quit() + + +def capture(): + try: + doc = App.openDocument(DOC) + + # Respect the document's own visibility. A doc saved from a GUI run has + # real parts shown and construction geometry (cut tools, un-cut stock) + # hidden; force-showing everything drags that scratch back into frame. + # + # Only if NOTHING is visible do we force parts on — that is the + # headless-authored case, where no view provider ever existed and the + # whole document would otherwise photograph as an empty scene. + solids = [ + o for o in doc.Objects + if getattr(o, "ViewObject", None) is not None + and getattr(o, "Shape", None) is not None + and o.Shape.Volume > 0 + ] + visible = [o for o in solids if o.ViewObject.Visibility] + if not visible: + say("RENDER document had nothing visible (headless-authored) - showing all") + for o in solids: + o.ViewObject.Visibility = True + visible = solids + shown = len(visible) + + view = Gui.activeDocument().activeView() + for name in VIEWS: + setter = SETTERS.get(name.strip()) + if not setter: + say("RENDER unknown view %r, skipped" % name) + continue + getattr(view, setter)() + Gui.SendMsgToActiveView("ViewFit") + # FreeCAD animates camera moves. Without letting that finish we + # photograph the camera in mid-flight — a "top" view rendered + # straight after an "iso" view comes out as a three-quarter shot, + # which is worse than useless: it looks plausible and is wrong. + _settle(SETTLE_MS) + path = os.path.join(OUT, "%s.png" % name.strip()) + view.saveImage(path, W, H, "Current") + say("RENDER %s" % path) + + say("RENDER ok - %d object(s) visible" % shown) + except Exception: + say("RENDER FAILED") + say(traceback.format_exc()) + finally: + _done["finished"] = True + shutdown() + + +def watchdog(): + if not _done["finished"]: + say("RENDER watchdog fired after %dms - closing" % WATCHDOG_MS) + shutdown() + + +# Give the GUI a moment to finish coming up before grabbing frames from it. +QtCore.QTimer.singleShot(1500, capture) +# Belt and braces: never leave an instance wedged open on an unforeseen stall. +QtCore.QTimer.singleShot(WATCHDOG_MS, watchdog) diff --git a/plugins-claude/freecad/lib/wwcut.py b/plugins-claude/freecad/lib/wwcut.py new file mode 100644 index 0000000..b9e3e61 --- /dev/null +++ b/plugins-claude/freecad/lib/wwcut.py @@ -0,0 +1,259 @@ +"""wwcut - turn a parts list into a cutting plan and a shopping list. + +A cut list says "I need a 610mm shelf". A *cut plan* says "buy three 8ft 1x10s, +and out of board #1 cut 610, 610, 610, 430, with 118 left over". The second one +is the thing you take to the lumberyard and the saw. This module does that. + +Two packing problems, both solved greedily (first-fit-decreasing). Optimal bin +packing is NP-hard and pointless here: with a dozen parts, FFD lands within a +board or two of optimal, and the saw is not that precise anyway. + + * linear - boards. Parts have one meaningful dimension (length); they get + packed into stock lengths (6ft, 8ft, ...). + * sheet - plywood/MDF. Parts are 2D; they get packed into sheets, using a + shelf/guillotine layout because that is how you actually cut a + sheet: rip it into strips, then crosscut each strip. + +Grain runs the length of a sheet. Rotating a part 90 degrees to squeeze it in +turns the grain the wrong way, so rotation is OFF by default for sheet goods — +a plan that saves half a sheet by cross-graining your cabinet sides is not a +saving. Pass allow_rotate=True if the material has no grain (MDF) or you do not +care. +""" + +IN = 25.4 +FT = 304.8 + +# What the yard actually sells. Override per project. +STOCK_LENGTHS = [6 * FT, 8 * FT, 10 * FT, 12 * FT, 16 * FT] +KERF = 3.2 # a thin-kerf blade, ~1/8" + +# Sheet goods come home in one of three shapes. You always *buy* a full 4x8; +# the store's panel saw is what turns it into something that fits in a truck. +# +# The first dimension is the grain direction. This is why a crosscut half and a +# ripped half are not interchangeable: crosscutting a 4x8 gives two 4x4s whose +# grain runs along a 4ft axis, while ripping gives two 2x8s whose grain still +# runs the full 8ft. Same area, different material. +# +# name piece (grain, cross) per full sheet +SHEET_PIECES = { + "full": ((8 * FT, 4 * FT), 1), + "half": ((4 * FT, 4 * FT), 2), # crosscut in half — the usual "half sheet" + "half-rip": ((8 * FT, 2 * FT), 2), # ripped in half — stays long and narrow +} +SHEET = SHEET_PIECES["full"][0] # backwards-compatible default + + +class Board: + """One physical board bought, and the parts cut from it.""" + + def __init__(self, stock, length): + self.stock = stock + self.length = length + self.cuts = [] # [(name, length)] + self.used = 0.0 # material consumed, kerf included + + def fits(self, length, kerf): + need = length + (kerf if self.cuts else 0.0) + return self.used + need <= self.length + 1e-6 + + def place(self, name, length, kerf): + self.used += length + (kerf if self.cuts else 0.0) + self.cuts.append((name, length)) + + @property + def offcut(self): + return self.length - self.used + + +class Sheet: + """One sheet, packed into horizontal strips (rip, then crosscut).""" + + def __init__(self, length, width): + self.length = length + self.width = width + self.strips = [] # [{"depth":, "y":, "x":, "parts":[(name,l,w)]}] + self.used_w = 0.0 + + def place(self, name, pl, pw, kerf): + # Try an open strip first: same idea as a rip already made. + for s in self.strips: + if pw <= s["depth"] + 1e-6 and s["x"] + pl + kerf <= self.length + 1e-6: + s["parts"].append((name, pl, pw)) + s["x"] += pl + kerf + return True + # Otherwise open a new strip, if there is width left to rip one. + if self.used_w + pw + kerf <= self.width + 1e-6: + self.strips.append( + {"depth": pw, "y": self.used_w, "x": pl + kerf, + "parts": [(name, pl, pw)]} + ) + self.used_w += pw + kerf + return True + return False + + @property + def area_used(self): + return sum(pl * pw for s in self.strips for _, pl, pw in s["parts"]) + + @property + def area(self): + return self.length * self.width + + +def plan_linear(parts, stock_lengths=None, kerf=KERF, max_length=None): + """Pack (name, length) parts into stock boards. Returns [Board]. + + Tries each stock length on its own and keeps whichever buys the least + material — a single length is what you actually want at the yard, and it + beats a mixed-length greedy plan often enough not to bother with more. + + `max_length` is a transport limit: the longest board you can actually get + home. A 12ft board is a fine plan and a useless one if it will not go in the + truck. + """ + stock_lengths = stock_lengths or STOCK_LENGTHS + items = sorted(parts, key=lambda p: -p[1]) + + longest = max((l for _, l in items), default=0.0) + + if max_length: + # A part longer than the limit is not a packing problem, it is a design + # problem: even cut to size it will not fit in the vehicle. + toolong = [(n, l) for n, l in items if l > max_length + 1e-6] + if toolong: + raise ValueError( + "part(s) longer than the %.0fmm transport limit: %s" + % (max_length, + ", ".join("%s (%.0fmm)" % (n, l) for n, l in toolong)) + ) + stock_lengths = [s for s in stock_lengths if s <= max_length + 1e-6] + if not stock_lengths: + raise ValueError( + "no stock length is within the %.0fmm transport limit" + % max_length + ) + + usable = [s for s in stock_lengths if s >= longest] + if not usable: + raise ValueError( + "no stock length holds a %.0fmm part (longest offered %.0fmm)" + % (longest, max(stock_lengths)) + ) + + best = None + for stock_len in usable: + boards = [] + for name, length in items: + for b in boards: + if b.fits(length, kerf): + b.place(name, length, kerf) + break + else: + b = Board(stock_len, stock_len) + b.place(name, length, kerf) + boards.append(b) + bought = sum(b.length for b in boards) + if best is None or bought < best[0]: + best = (bought, boards) + return best[1] + + +def plan_sheets(parts, sheet=SHEET, kerf=KERF, allow_rotate=False): + """Pack (name, length, width) parts into sheets of one size. Returns [Sheet].""" + sl, sw = sheet + items = sorted(parts, key=lambda p: (-p[2], -p[1])) # widest strip first + + sheets = [] + for name, pl, pw in items: + placed = False + for s in sheets: + if s.place(name, pl, pw, kerf): + placed = True + break + if allow_rotate and s.place(name, pw, pl, kerf): + placed = True + break + if placed: + continue + + s = Sheet(sl, sw) + if not s.place(name, pl, pw, kerf): + if not (allow_rotate and s.place(name, pw, pl, kerf)): + raise ValueError( + "%s (%.0f x %.0f) does not fit a %.0f x %.0f piece" + % (name, pl, pw, sl, sw) + ) + sheets.append(s) + return sheets + + +class SheetBuy: + """A sheet plan plus what it costs you at the till and in the truck.""" + + def __init__(self, piece_name, piece, pieces, per_full): + self.piece_name = piece_name # "full" | "half" | "half-rip" + self.piece = piece # (grain, cross) mm + self.pieces = pieces # [Sheet] + self.per_full = per_full # pieces obtainable from one full sheet + + @property + def full_sheets(self): + """You buy full sheets. The store's saw is what gives you halves.""" + n = len(self.pieces) + return (n + self.per_full - 1) // self.per_full + + @property + def spare_pieces(self): + return self.full_sheets * self.per_full - len(self.pieces) + + @property + def yield_pct(self): + used = sum(s.area_used for s in self.pieces) + bought = self.full_sheets * SHEET_PIECES["full"][0][0] * \ + SHEET_PIECES["full"][0][1] + return 100.0 * used / bought if bought else 0.0 + + +def choose_sheets(parts, kerf=KERF, allow_rotate=False, max_length=None, + prefer=None): + """Pick a sheet piece size and pack into it. Returns SheetBuy. + + You always buy a full 4x8 — the choice is what the store cuts it into before + it goes in the truck. So candidates are ranked by full sheets purchased, and + ties are broken toward the *smaller* piece, because the smaller piece is the + one that fits in the vehicle and on the bench. + + `max_length` drops any piece too big to carry. `prefer` forces a piece name. + """ + names = [prefer] if prefer else list(SHEET_PIECES) + + best = None + failures = [] + for name in names: + piece, per_full = SHEET_PIECES[name] + if max_length and max(piece) > max_length + 1e-6: + failures.append("%s (%.0fmm > %.0fmm limit)" + % (name, max(piece), max_length)) + continue + try: + packed = plan_sheets(parts, piece, kerf, allow_rotate) + except ValueError as exc: + failures.append("%s (%s)" % (name, exc)) + continue + buy = SheetBuy(name, piece, packed, per_full) + rank = (buy.full_sheets, max(piece)) # fewest sheets, then smallest piece + if best is None or rank < best[0]: + best = (rank, buy) + + if best is None: + raise ValueError( + "no sheet size works: %s" % ("; ".join(failures) or "no candidates") + ) + return best[1] + + +def board_feet(stock_t, stock_w, length): + """Board feet — how lumber is actually priced.""" + return (stock_t / IN) * (stock_w / IN) * (length / IN) / 144.0 diff --git a/plugins-claude/freecad/lib/wwkit.py b/plugins-claude/freecad/lib/wwkit.py new file mode 100644 index 0000000..e9aeef7 --- /dev/null +++ b/plugins-claude/freecad/lib/wwkit.py @@ -0,0 +1,722 @@ +"""wwkit - helpers for scripting FreeCAD models that a human then inspects. + +Written for a script-generates / human-inspects loop: an agent authors the +model, FreeCAD renders it, the human pans and rotates and says what's wrong, +the agent edits the script. + +Design notes worth knowing before you extend this: + +* **Shapes first, one document object per real part.** Joinery mutates a part's + shape in place (``part.cut(tool)``) instead of chaining ``Part::Cut`` feature + objects. A dado-ed panel is still one object called ``Panel_Left``, not a + tree of anonymous cuts, and the document contains no construction scratch to + hide, colour, or accidentally render. + +* **Select edges by geometry, never by index.** ``Edge7`` is renumbered by a + recompute — that is FreeCAD's topological naming problem, and it is what + breaks mouse-built models. Selecting by predicate (``vertical_edges``, + ``edges_at``) is immune to it by construction. Script-driven modelling can + simply not have FreeCAD's most notorious bug. + +FreeCAD behaviours this module absorbs: + +1. Headless documents have no view providers: ``obj.ViewObject`` is ``None``, + so a headless-authored file opens with everything hidden and the 3D view + looks empty. Colour/visibility are only set when ``App.GuiUp``. +2. Non-ASCII in a ``print()`` raises inside FreeCAD's console and aborts the + run - after printing results, before exporting, leaving stale artefacts that + look current. ``say()`` strips to ASCII. +3. Booleans yield Compounds, so ``ShapeType != "Solid"`` says nothing about + printability. Judge from the mesh: closed, manifold, no self-intersections. +""" + +import json +import os +import re + +import FreeCAD as App +import Mesh +import Part + +# --- units --------------------------------------------------------------- +# Internally everything is millimetres, because FreeCAD is. Input and reports +# can be imperial, because lumber is. + +IN = 25.4 +FT = 304.8 + +# Reports only; geometry is always mm. +# "mm" - metric, the default: this is a metric shop +# "in" - imperial +# "both" - metric with the imperial in parentheses, for the trip to the yard +UNITS = os.environ.get("WW_UNITS", "mm") + + +def inch(x): + """Inches to mm. Accepts 3.5, "3/4", "1 1/2", "1-1/2".""" + if isinstance(x, str): + total = 0.0 + for part in x.strip().replace("-", " ").split(): + if "/" in part: + num, den = part.split("/") + total += float(num) / float(den) + else: + total += float(part) + x = total + return float(x) * IN + + +def ft(x): + return float(x) * FT + + +def _frac(inches, denom=16): + """Inches as a shop fraction: 9.03 -> 9 1/32 is silly, 9 -> 9.""" + whole = int(inches) + num = round((inches - whole) * denom) + if num == 0: + return "%d" % whole + if num == denom: + return "%d" % (whole + 1) + while num % 2 == 0 and denom % 2 == 0: + num //= 2 + denom //= 2 + return "%d-%d/%d" % (whole, num, denom) if whole else "%d/%d" % (num, denom) + + +def fmt(mm): + """A length for a report, honouring UNITS.""" + if UNITS == "in": + return '%s"' % _frac(mm / IN) + if UNITS == "both": + return '%.1f (%s")' % (mm, _frac(mm / IN)) + return "%.1f" % mm + + +def fmt_stock(mm): + """A stock length the way a lumberyard sells it: 2438 -> '8 ft'.""" + feet = mm / FT + if abs(feet - round(feet)) < 0.02: + return "%d ft" % round(feet) + return "%.0f mm" % mm + + +# --- stock --------------------------------------------------------------- +# Nominal sizes are lies. A "2x4" is 1.5" x 3.5"; "3/4" plywood is usually +# 23/32". Modelling the nominal number is the classic way to produce a design +# that cannot be built. These are actual dimensions, in mm. + +LUMBER = { # nominal -> (thickness, width), surfaced softwood + "1x2": (inch("3/4"), inch("1 1/2")), + "1x3": (inch("3/4"), inch("2 1/2")), + "1x4": (inch("3/4"), inch("3 1/2")), + "1x6": (inch("3/4"), inch("5 1/2")), + "1x8": (inch("3/4"), inch("7 1/4")), + "1x10": (inch("3/4"), inch("9 1/4")), + "1x12": (inch("3/4"), inch("11 1/4")), + "2x2": (inch("1 1/2"), inch("1 1/2")), + "2x3": (inch("1 1/2"), inch("2 1/2")), + "2x4": (inch("1 1/2"), inch("3 1/2")), + "2x6": (inch("1 1/2"), inch("5 1/2")), + "2x8": (inch("1 1/2"), inch("7 1/4")), + "2x10": (inch("1 1/2"), inch("9 1/4")), + "2x12": (inch("1 1/2"), inch("11 1/4")), + "4x4": (inch("3 1/2"), inch("3 1/2")), + "6x6": (inch("5 1/2"), inch("5 1/2")), +} + +# Sheet goods, actual thickness. These vary by product and supplier — measure +# yours and override rather than trusting the table blindly. +PLY = { # US softwood/hardwood ply: undersized against nominal + "1/4": inch("7/32"), + "3/8": inch("11/32"), + "1/2": inch("15/32"), + "5/8": inch("19/32"), + "3/4": inch("23/32"), +} +BALTIC = {"3mm": 3.0, "6mm": 6.0, "9mm": 9.0, "12mm": 12.0, "18mm": 18.0} +MDF = { # MDF is true to nominal, unlike ply + "1/4": inch("1/4"), + "1/2": inch("1/2"), + "3/4": inch("3/4"), +} + +# --- appearance ---------------------------------------------------------- +WOOD = (0.80, 0.60, 0.35) +PRINTED = (0.20, 0.55, 0.85) + +PLA_DENSITY = 1.24 # g/cm^3 +TESSELLATION = 0.05 # mm deviation for the printability mesh check + +_LOG = os.environ.get("WW_LOG") + + +def say(msg): + """print(), minus the characters that abort a FreeCAD console run. + + Also appends to $WW_LOG. Under the GUI, FreeCAD rebinds Python's stdout to + its Report View, so print() never reaches the process pipe — the log file + is the only channel that works in both headless and GUI runs. + """ + line = str(msg).encode("ascii", "replace").decode("ascii") + print(line) + if _LOG: + try: + with open(_LOG, "a") as fh: + fh.write(line + "\n") + except OSError: + pass + + +# --- shape builders (pure; no document objects) -------------------------- +def solid(dx, dy, dz, at=(0, 0, 0)): + return Part.makeBox(dx, dy, dz, App.Vector(*at)) + + +def cyl(radius, height, at=(0, 0, 0), axis=(0, 0, 1)): + return Part.makeCylinder(radius, height, App.Vector(*at), App.Vector(*axis)) + + +# --- edge selection by geometry, not by index ---------------------------- +def edges_where(shape, pred): + return [e for e in shape.Edges if _safe(pred, e)] + + +def _safe(pred, edge): + try: + return bool(pred(edge)) + except Exception: + return False + + +def vertical_edges(shape): + """Every edge running along Z — e.g. the four corners of an upright post.""" + + def pred(e): + t = e.tangentAt(e.FirstParameter) + return abs(t.z) > 0.999 + + return edges_where(shape, pred) + + +def edges_at(shape, z=None, tol=1e-6): + """Every edge lying in a given Z plane — e.g. the top rim of a box.""" + + def pred(e): + return all(abs(v.Point.z - z) < tol for v in e.Vertexes) + + return edges_where(shape, pred) + + +class Part_: + """One real part: a named solid with a material role.""" + + def __init__(self, obj, kind, nominal=None, stock=None, form=None): + self.obj = obj + self.kind = kind # "wood" | "printed" + self.nominal = nominal # (dx, dy, dz) as authored, for the cut list + self.stock = stock # e.g. "2x4" — the nominal name you buy it under + self.form = form # "board" | "sheet" | None — how the planner packs it + + @property + def name(self): + return self.obj.Name + + @property + def shape(self): + return self.obj.Shape + + @property + def bbox(self): + return self.obj.Shape.BoundBox + + def cut(self, tool): + """Subtract a shape. The part stays one object with the same name.""" + self.obj.Shape = self.obj.Shape.cut(tool) + return self + + def fuse(self, tool): + self.obj.Shape = self.obj.Shape.fuse(tool) + return self + + def fillet(self, radius, pred=vertical_edges): + edges = pred(self.obj.Shape) if callable(pred) else pred + if edges: + self.obj.Shape = self.obj.Shape.makeFillet(radius, edges) + return self + + def chamfer(self, size, pred=vertical_edges): + edges = pred(self.obj.Shape) if callable(pred) else pred + if edges: + self.obj.Shape = self.obj.Shape.makeChamfer(size, edges) + return self + + +class Model: + """A FreeCAD document plus the bookkeeping a build actually needs.""" + + def __init__(self, name): + # FreeCAD sanitises a document's internal Name to [A-Za-z0-9_], so + # newDocument("bracket-box") yields a doc named "bracket_box". Sanitise + # first, or the close-and-replace below silently misses and every + # live-reload stacks another document: bracket_box, bracket_box1, ... + name = re.sub(r"[^A-Za-z0-9_]", "_", name) + if name in App.listDocuments(): + App.closeDocument(name) + self.doc = App.newDocument(name) + self.parts = [] + + # -- construction ----------------------------------------------------- + def add(self, name, shape, kind="wood", nominal=None, stock=None, form=None): + o = self.doc.addObject("Part::Feature", name) + o.Shape = shape + p = Part_(o, kind, nominal=nominal, stock=stock, form=form) + self.parts.append(p) + return p + + def box(self, name, dx, dy, dz, at=(0, 0, 0), kind="wood"): + return self.add(name, solid(dx, dy, dz, at), kind, nominal=(dx, dy, dz)) + + def board(self, name, stock, length, at=(0, 0, 0), + length_axis="x", thickness_axis="z", rip=None, kind="wood"): + """A board of real dimensional lumber. `stock` is nominal ("2x4"). + + Orientation is given by two axes rather than one, because one is + ambiguous: a board lying flat and the same board standing on edge share + a length axis but are not the same part. Width takes the axis left over. + """ + if stock not in LUMBER: + raise KeyError("unknown lumber %r; known: %s" + % (stock, ", ".join(sorted(LUMBER)))) + if length_axis == thickness_axis: + raise ValueError("length and thickness cannot share an axis") + t, w = LUMBER[stock] + if rip: # ripped narrower than the stock came + w = rip + width_axis = ({"x", "y", "z"} - {length_axis, thickness_axis}).pop() + by_axis = {length_axis: length, thickness_axis: t, width_axis: w} + dims = (by_axis["x"], by_axis["y"], by_axis["z"]) + return self.add(name, solid(*dims, at=at), kind, nominal=dims, + stock=stock, form="board") + + def panel(self, name, stock, length, width, at=(0, 0, 0), + thickness_axis="z", table=PLY, kind="wood"): + """A sheet-goods panel. `stock` keys into PLY / BALTIC / MDF. + + thickness_axis matters: a cabinet bottom is thick in Z, a back panel is + thick in Y. Getting it wrong produces a part with the right area and a + nonsensical thickness. + """ + if stock not in table: + raise KeyError("unknown sheet %r; known: %s" + % (stock, ", ".join(sorted(table)))) + t = table[stock] + others = [a for a in "xyz" if a != thickness_axis] + by_axis = {thickness_axis: t, others[0]: length, others[1]: width} + dims = (by_axis["x"], by_axis["y"], by_axis["z"]) + return self.add(name, solid(*dims, at=at), kind, + nominal=dims, stock=stock, form="sheet") + + def place(self, name, shape, at=(0, 0, 0), rot_z=0.0, kind="printed"): + """Stamp an already-built shape into the model at a placement.""" + p = self.add(name, shape, kind) + p.obj.Placement = App.Placement( + App.Vector(*at), App.Rotation(App.Vector(0, 0, 1), rot_z) + ) + return p + + def of_kind(self, kind): + return [p for p in self.parts if p.kind == kind] + + # -- joinery ---------------------------------------------------------- + # Every joint is a boolean cut. What these buy you is not the boolean but + # the arithmetic: positions derived from the part's own bounding box, and a + # clearance parameter in one obvious place. + + def notch(self, part, size, at): + """The primitive: subtract a box in absolute coordinates.""" + return part.cut(solid(*size, at=at)) + + def trench(self, part, face, along, pos, width, depth): + """A flat-bottomed channel cut into one face. The joinery primitive. + + `face` the surface it opens on: +x -x +y -y +z -z + `along` the axis it runs along (must not be the face's own axis) + `pos` where it starts on the remaining axis + `width` its extent on that remaining axis + `depth` how far it cuts into the part from `face` + + A dado is this in the middle of a face; a rabbet is this pushed flush + against an edge, which is why both are wrappers rather than separate + geometry. + """ + sign, nax = face[0], face[1] + if nax not in "xyz" or sign not in "+-": + raise ValueError("face must be like '+x', got %r" % face) + if along == nax: + raise ValueError("a trench cannot run along its own face normal") + third = ({"x", "y", "z"} - {nax, along}).pop() + + b = part.bbox + lo = {"x": b.XMin, "y": b.YMin, "z": b.ZMin} + hi = {"x": b.XMax, "y": b.YMax, "z": b.ZMax} + length = {"x": b.XLength, "y": b.YLength, "z": b.ZLength} + + size = {along: length[along], third: width, nax: depth} + at = { + along: lo[along], + third: pos, + nax: (hi[nax] - depth) if sign == "+" else lo[nax], + } + return part.cut( + solid(size["x"], size["y"], size["z"], + at=(at["x"], at["y"], at["z"])) + ) + + def dado(self, part, face, along, pos, width, depth): + """A trench across the middle of a face, to receive another panel.""" + return self.trench(part, face, along, pos, width, depth) + + def rabbet(self, part, face, edge, width, depth): + """A step along one edge — the classic case-back joint. + + `edge` (e.g. "+y") is the boundary the step is flush against. + """ + eax = edge[1] + b = part.bbox + lo = {"x": b.XMin, "y": b.YMin, "z": b.ZMin} + hi = {"x": b.XMax, "y": b.YMax, "z": b.ZMax} + pos = (hi[eax] - width) if edge[0] == "+" else lo[eax] + along = ({"x", "y", "z"} - {face[1], eax}).pop() + return self.trench(part, face, along, pos, width, depth) + + def mortise(self, part, face, at, size, depth): + """A blind rectangular pocket in `face`. + + `at` and `size` are given on the two axes that are not the face normal, + in x,y,z order (the face's own axis is ignored). + """ + sign, nax = face[0], face[1] + others = [a for a in "xyz" if a != nax] + b = part.bbox + lo = {"x": b.XMin, "y": b.YMin, "z": b.ZMin} + hi = {"x": b.XMax, "y": b.YMax, "z": b.ZMax} + + sz = dict(zip(others, size)) + pt = dict(zip(others, at)) + sz[nax] = depth + pt[nax] = (hi[nax] - depth) if sign == "+" else lo[nax] + return part.cut( + solid(sz["x"], sz["y"], sz["z"], at=(pt["x"], pt["y"], pt["z"])) + ) + + def tenon(self, part, end, size, length): + """Reduce a board's end to a tongue by cutting away the shoulders. + + `end` is +x or -x; `size` is the (width, thickness) of the tongue, + centred in the board. + """ + b = part.bbox + tw, tt = size + x = b.XMax - length if end == "+x" else b.XMin + y0 = b.YMin + (b.YLength - tw) / 2.0 + z0 = b.ZMin + (b.ZLength - tt) / 2.0 + # Four shoulders around the tongue. + part.cut(solid(length, y0 - b.YMin, b.ZLength, at=(x, b.YMin, b.ZMin))) + part.cut(solid(length, b.YMax - (y0 + tw), b.ZLength, at=(x, y0 + tw, b.ZMin))) + part.cut(solid(length, tw, z0 - b.ZMin, at=(x, y0, b.ZMin))) + part.cut(solid(length, tw, b.ZMax - (z0 + tt), at=(x, y0, z0 + tt))) + return part + + def hole(self, part, at, dia, depth, axis=(0, 0, -1)): + start = App.Vector(*at) + return part.cut( + Part.makeCylinder(dia / 2.0, depth, start, App.Vector(*axis)) + ) + + # -- checks ----------------------------------------------------------- + def check_printable(self, shape, label="part"): + """Would a slicer accept this? Mesh-level truth, not ShapeType.""" + if isinstance(shape, Part_): + label, shape = shape.name, shape.shape + mesh = Mesh.Mesh() + mesh.addFacets(shape.tessellate(TESSELLATION)) + ok = ( + shape.isValid() + and shape.isClosed() + and mesh.isSolid() + and not mesh.hasNonManifolds() + and not mesh.hasSelfIntersections() + ) + say("PRINTABLE %-14s %s (valid=%s closed=%s manifold=%s no-self-isect=%s)" + % (label, "OK" if ok else "FAIL", shape.isValid(), shape.isClosed(), + not mesh.hasNonManifolds(), not mesh.hasSelfIntersections())) + return ok + + def check_clashes(self, tol=1e-6): + """Two parts sharing volume is a part that will not physically fit.""" + clashes = [] + for i, a in enumerate(self.parts): + for b in self.parts[i + 1:]: + vol = a.shape.common(b.shape).Volume + if vol > tol: + clashes.append((a.name, b.name, vol)) + say("CLASH %s x %s %.2f mm^3" % (a.name, b.name, vol)) + say("CLASH %d interference pair(s)" % len(clashes)) + return clashes + + 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 + + def envelope(self): + """Overall bounding box — does the finished thing fit through a door?""" + boxes = [p.bbox for p in self.parts] + if not boxes: + return None + x = max(b.XMax for b in boxes) - min(b.XMin for b in boxes) + y = max(b.YMax for b in boxes) - min(b.YMin for b in boxes) + z = max(b.ZMax for b in boxes) - min(b.ZMin for b in boxes) + say("ENVELOPE %s x %s x %s" % (fmt(x), fmt(y), fmt(z))) + return (x, y, z) + + # -- reports ---------------------------------------------------------- + def cutlist(self): + wood = self.of_kind("wood") + if not wood: + return [] + say("-" * 64) + say("CUT LIST (wood%s)" % ("" if UNITS == "mm" else ", inches")) + rows = [] + for p in wood: + dims = sorted(p.nominal or _bbox_dims(p.shape), reverse=True) + rows.append((p.name, p.stock, dims)) + say(" %-16s %-6s %8s x %8s x %8s" + % (p.name, p.stock or "", fmt(dims[0]), fmt(dims[1]), fmt(dims[2]))) + return rows + + def cutplan(self, stock_lengths=None, kerf=None, allow_rotate=False, + max_length=None, sheet_piece=None): + """From a parts list to a shopping list and a cutting order. + + `cutlist()` says what parts you need. This 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. + + max_length the longest piece you can actually get home. Stock longer + than this is never offered, however well it would pack. + sheet_piece "full" | "half" | "half-rip", or None to choose. You always + buy a full 4x8; this is what you ask the store to cut it + into before it goes in the vehicle. + + Parts made with box() are skipped: no stock, so nothing to buy them + from. Use board() or panel() to have them planned. + """ + import wwcut + + kerf = wwcut.KERF if kerf is None else kerf + + boards = [p for p in self.parts if p.form == "board"] + sheets = [p for p in self.parts if p.form == "sheet"] + skipped = [p for p in self.parts + if p.kind == "wood" and p.form is None] + + say("=" * 64) + limit = " (transport limit %s)" % fmt(max_length) if max_length else "" + say("CUT PLAN (kerf %s)%s" % (fmt(kerf), limit)) + + buy = [] + + # --- boards, grouped by the stock you buy them as ----------------- + by_stock = {} + for p in boards: + by_stock.setdefault(p.stock, []).append(p) + + for stock in sorted(by_stock): + items = [(p.name, max(p.nominal)) for p in by_stock[stock]] + plan = wwcut.plan_linear(items, stock_lengths, kerf, max_length) + say("") + say(" %s -- %d board(s) of %s" + % (stock, len(plan), fmt_stock(plan[0].length))) + for i, b in enumerate(plan, 1): + cuts = ", ".join("%s (%s)" % (n, fmt(l)) for n, l in b.cuts) + say(" #%d: %s" % (i, cuts)) + say(" offcut %s" % fmt(b.offcut)) + buy.append("%d x %s @ %s" + % (len(plan), stock, fmt_stock(plan[0].length))) + + # --- sheet goods, grouped by thickness ---------------------------- + by_sheet = {} + for p in sheets: + by_sheet.setdefault(p.stock, []).append(p) + + for stock in sorted(by_sheet): + items = [] + for p in by_sheet[stock]: + dims = sorted(p.nominal, reverse=True) + items.append((p.name, dims[0], dims[1])) + + sb = wwcut.choose_sheets(items, kerf, allow_rotate, + max_length, sheet_piece) + say("") + say(" %s ply -- %d x '%s' piece (%s x %s), from %d full sheet(s)" + % (stock, len(sb.pieces), sb.piece_name, + fmt_stock(sb.piece[0]), fmt_stock(sb.piece[1]), + sb.full_sheets)) + say(" %.0f%% of the bought material used%s" + % (sb.yield_pct, "" if allow_rotate else ", grain respected")) + for i, s in enumerate(sb.pieces, 1): + say(" piece #%d:" % i) + for strip in s.strips: + parts = ", ".join("%s (%s x %s)" % (n, fmt(pl), fmt(pw)) + for n, pl, pw in strip["parts"]) + say(" rip %s wide: %s" % (fmt(strip["depth"]), parts)) + + if sb.piece_name == "full": + buy.append("%d x %s full sheet" % (sb.full_sheets, stock)) + else: + cut = ("cut in half" if sb.piece_name == "half" + else "ripped in half lengthwise") + spare = (", %d spare" % sb.spare_pieces) if sb.spare_pieces else "" + buy.append("%d x %s full sheet (%s at the store%s)" + % (sb.full_sheets, stock, cut, spare)) + + if skipped: + say("") + say(" NOTE: %d part(s) not planned (made with box(), so no stock): %s" + % (len(skipped), ", ".join(p.name for p in skipped))) + + say("") + say("BUY " + ("; ".join(buy) if buy else "nothing to plan")) + say("=" * 64) + return buy + + 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 + + # -- finish ----------------------------------------------------------- + def _stamp_interop(self): + """Tag parts with the dprojects/Woodworking property convention. + + That addon's report tool reads Width/Height/Length off Part::Box, and + silently *drops* anything else — including boolean results like ours, + which is every part with a joint cut into it. Stamping the properties it + looks for means a user who opens our document in FreeCAD's GUI with the + addon installed gets a working cut list, instead of a blank one. + + Cheap interop. We do not depend on the addon; this just doesn't preclude + it. (Their tooling is GUI/Selection-bound and cannot be called headless, + so importing it was never an option.) + """ + for p in self.parts: + b = p.shape.BoundBox + for prop, val in ( + ("Woodworking_Length", b.XLength), + ("Woodworking_Width", b.YLength), + ("Woodworking_Height", b.ZLength), + ): + try: + if not hasattr(p.obj, prop): + p.obj.addProperty("App::PropertyLength", prop, "Woodworking") + setattr(p.obj, prop, val) + except Exception: + pass # interop is a nicety; never fail a build over it + + def finish(self, out_dir, stl_of=None, step=True): + self.doc.recompute() + self._stamp_interop() + self._style() + + base = os.path.join(out_dir, self.doc.Name) + self.doc.saveAs(base + ".FCStd") + written = [base + ".FCStd"] + self._write_intent(out_dir) + + printed = self.of_kind("printed") + if printed: + target = next((p for p in printed if p.name == stl_of), printed[0]) + Mesh.export([target.obj], base + ".stl") + written.append(base + ".stl") + + if step and self.parts: + import Import + + Import.export([p.obj for p in self.parts], base + ".step") + written.append(base + ".step") + + say("WROTE " + ", ".join(os.path.basename(w) for w in written)) + + # `fcrun --gui` is a one-shot build (it exists to bake view state into + # the document), not a session. Close ourselves, or we leak a FreeCAD + # window that no watcher is listening to — and so one fcquit cannot + # close, leaving a hard kill as the only way out. fclive clears this. + if App.GuiUp and os.environ.get("WW_GUI_ONESHOT"): + import FreeCADGui as Gui + + say("EXIT one-shot GUI build complete, closing") + for name in list(App.listDocuments()): + try: + App.closeDocument(name) + except Exception: + pass + Gui.getMainWindow().close() + + return written + + def _write_intent(self, out_dir): + """Record where the script *meant* to put each part. + + This is what lets the loop run both ways. When the user drags a part in + the GUI, fcsnap diffs the live placement against this file, and the move + becomes a concrete proposal ("Panel_Left moved +12mm in X") that can be + folded back into the script — instead of being silently destroyed by the + next rebuild. + """ + intent = { + "doc": self.doc.Name, + "parts": { + p.name: { + "kind": p.kind, + "pos": list(p.obj.Placement.Base), + "yaw": p.obj.Placement.Rotation.toEuler()[0], + } + for p in self.parts + }, + } + path = os.path.join(out_dir, self.doc.Name + ".intent.json") + with open(path, "w") as fh: + json.dump(intent, fh, indent=2) + App.__ww_intent__ = path # breadcrumb the live watcher can find + return path + + def _style(self): + """Colour and reveal parts. Silently skipped headless (no ViewObject).""" + if not App.GuiUp: + say("VIEW skipped (headless - no view providers)") + return + import FreeCADGui as Gui + + for p in self.parts: + vo = p.obj.ViewObject + vo.ShapeColor = WOOD if p.kind == "wood" else PRINTED + if p.kind == "wood": + vo.Transparency = 35 + vo.Visibility = True + say("VIEW %d part(s) coloured and shown" % len(self.parts)) + Gui.updateGui() + + +def _bbox_dims(shape): + b = shape.BoundBox + return [b.XLength, b.YLength, b.ZLength] diff --git a/plugins-claude/freecad/scripts/approve-own-scripts.sh b/plugins-claude/freecad/scripts/approve-own-scripts.sh new file mode 100755 index 0000000..1c345ea --- /dev/null +++ b/plugins-claude/freecad/scripts/approve-own-scripts.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# approve-own-scripts.sh — PreToolUse hook to auto-approve a plugin's own files. +# +# Two jobs, by tool: +# Bash — auto-allow any command whose executable lives under the +# plugin's scripts/ directory (and guard outward PR publish). +# Read|Glob|Grep — auto-allow reading any file under the plugin's own root. +# Plugins live in an out-of-workspace install cache, so a +# skill that Reads its own ${CLAUDE_PLUGIN_ROOT}/reference/* +# otherwise prompts the user on every invocation. +# +# Falls through (exit 0, no output) for anything that doesn't match, letting +# other hooks or the user decide. +# +# Claude Code hooks.json — register for both tool groups: +# { +# "hooks": { +# "PreToolUse": [ +# { +# "matcher": "Bash", +# "hooks": [{ +# "type": "command", +# "command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/approve-own-scripts.sh" +# }] +# }, +# { +# "matcher": "Read|Glob|Grep", +# "hooks": [{ +# "type": "command", +# "command": "bash ${CLAUDE_PLUGIN_ROOT}/scripts/approve-own-scripts.sh" +# }] +# } +# ] +# } +# } +# +# Copilot CLI hooks.json (preToolUse has no matcher — the script self-filters +# by tool name, so a single entry covers both groups): +# { +# "version": 1, +# "hooks": { +# "preToolUse": [{ +# "type": "command", +# "bash": "bash ${COPILOT_PLUGIN_ROOT}/scripts/approve-own-scripts.sh" +# }] +# } +# } + +set -euo pipefail + +HOOK_INPUT=$(cat) +# shellcheck source=hook-compat.sh +source "$(dirname "$0")/hook-compat.sh" + +# CLAUDE_PLUGIN_ROOT (Claude Code) or COPILOT_PLUGIN_ROOT (Copilot CLI) is set +# to the installed plugin directory. If neither is set, we can't determine +# which files belong to this plugin. We resolve it here but DON'T exit yet: +# the Bash PR-publish guard below must fire even when the root is unknown. +PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-${COPILOT_PLUGIN_ROOT:-}}" + +# --- Read/Glob/Grep: auto-approve reads of this plugin's own bundled files --- +# Read carries the target in file_path; Glob/Grep use path. Pull whichever is +# present directly from the payload — `// empty` collapses both "missing key" +# and JSON null so a pathless Grep yields an empty string (we don't lean on +# HOOK_FILE_PATH here: its copilot path renders a missing key as "null"). +if [[ "$HOOK_TOOL_NAME" == "Read" || "$HOOK_TOOL_NAME" == "Glob" || "$HOOK_TOOL_NAME" == "Grep" ]]; then + # Without a plugin root we can't tell which files are "ours" — defer. + [[ -n "$PLUGIN_ROOT" ]] || exit 0 + if [[ "$HOOK_FORMAT" == "copilot" ]]; then + read_target=$(echo "$HOOK_INPUT" | jq -r 'try ((.toolArgs | fromjson) | (.file_path // .path // empty)) catch empty' 2>/dev/null || echo "") + else + read_target=$(echo "$HOOK_INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty') + fi + # Empty target (e.g. a project-wide Grep with no path) or traversal: defer. + [[ -n "$read_target" && "$read_target" != *".."* ]] || exit 0 + if [[ "$read_target" == "${PLUGIN_ROOT}/"* ]]; then + hook_allow "plugin file: ${read_target#"${PLUGIN_ROOT}/"}" + fi + exit 0 +fi + +[[ "$HOOK_TOOL_NAME" == "Bash" ]] || exit 0 + +# --- Outward VCS publish guard (applies to ALL Bash, not just own scripts) --- +# Never SILENTLY approve creating or merging a pull request — whether it goes +# through this plugin's git-cli wrapper OR a direct `gh`/`tea` invocation that +# would otherwise sidestep the wrapper (and likely hit a user allowlist). +# Publishing/merging a PR triggers CI and any auto-merge automation, so it must +# be confirmed by the user rather than waved through. We require BOTH a known +# VCS CLI token (gh / tea / git-cli) AND a "pr create" | "pr merge" subcommand, +# so read-only forms (`pr auto-merge-status`) and reversible ones (`pr close`) +# still fall through. On Copilot CLI, hook_ask degrades to a hard deny (there is +# no "ask" there) — meaning the user simply runs the publish step manually. +# +# Enabling auto-merge is covered: the real path is `gh pr merge --auto`, which +# the `pr merge` arm already catches. We deliberately do NOT broaden to a bare +# "auto-merge" token — that would also trap the read-only `pr auto-merge-status` +# call the session-end flow relies on. (`pr review --approve`, `release create` +# etc. are out of scope by design — this guard is narrowly about opening/merging +# a PR.) +if [[ "$HOOK_COMMAND" =~ (^|[[:space:]/])(gh|tea|git-cli)[[:space:]] ]] && + [[ "$HOOK_COMMAND" =~ (^|[[:space:]])pr[[:space:]]+(create|merge)([[:space:]]|$) ]]; then + # NOTE: BASH_REMATCH below reflects the SECOND [[ =~ ]] (the pr subcommand + # test, evaluated last), so [2] is "create"|"merge". Do not reorder the two + # conditions — swapping them would make this message read "pr gh"/"pr tea". + hook_ask "Confirm before publishing: this opens/merges a PR (\`pr ${BASH_REMATCH[2]}\`), which triggers CI and auto-merge. Session flows must stop for your review first — approve only if you intend to publish right now." + exit 0 +fi + +# The scripts-dir match below needs a known plugin root; the VCS guard above +# did not. Defer now if we still don't have one. +[[ -n "$PLUGIN_ROOT" ]] || exit 0 + +SCRIPTS_DIR="${PLUGIN_ROOT}/scripts" + +# Check if the command invokes a script from this plugin's scripts/ directory. +# Handles both direct execution (/path/to/scripts/foo ...) and via bash/sh +# (bash /path/to/scripts/foo ...). +cmd="$HOOK_COMMAND" + +# Strip leading bash/sh interpreter if present +cmd_path="$cmd" +if [[ "$cmd_path" =~ ^(bash|sh)[[:space:]]+(.*) ]]; then + cmd_path="${BASH_REMATCH[2]}" +fi + +# Extract just the executable path (first token) +read -r exec_path _ <<<"$cmd_path" + +# Reject path traversal attempts +if [[ "$exec_path" == *".."* ]]; then + exit 0 +fi + +# Match against this plugin's scripts directory +if [[ "$exec_path" == "${SCRIPTS_DIR}/"* ]]; then + hook_allow "plugin script: ${exec_path#"${SCRIPTS_DIR}/"}" + exit 0 +fi + +# No match — fall through to other hooks / user prompt +exit 0 diff --git a/plugins-claude/freecad/scripts/fclive b/plugins-claude/freecad/scripts/fclive new file mode 100755 index 0000000..640d8a7 --- /dev/null +++ b/plugins-claude/freecad/scripts/fclive @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# fclive — open FreeCAD watching a model script, rebuilding on every save. +# +# Leave this window open for the whole design session. The agent edits the +# script; the model updates in place with your camera untouched. +# +# Usage: +# fclive model.py # foreground (Ctrl-C to stop) +# fclive -b model.py # background, so the agent keeps working +# +# WW_POLL_MS tunes the watch interval (default 1000). + +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +LIB_DIR="$(cd "$HERE/../lib" && pwd)" +# shellcheck source=./fcsession +source "$HERE/fcsession" + +BG=0 +if [[ "${1:-}" == "-b" ]]; then + BG=1 + shift +fi + +SCRIPT="${1:-}" +if [[ -z "$SCRIPT" || ! -f "$SCRIPT" ]]; then + echo "fclive: usage: fclive [-b] " >&2 + exit 2 +fi + +WW_WATCH="$(cd "$(dirname "$SCRIPT")" && pwd)/$(basename "$SCRIPT")" +export WW_WATCH +_fcsession_paths "$SCRIPT" +# A live session must NOT self-close when the model script finishes building. +export WW_GUI_ONESHOT="" +# Stale control files would fire the moment we start: quitting us, or snapping +# into a directory from a previous session. +rm -f "$WW_STOP" "$WW_SNAP" + +if [[ "$BG" -eq 1 ]]; then + # Log rather than discard: in background the rebuild/traceback output is the + # only way to tell a successful reload from a script that blew up. Note the + # watcher writes this file itself — under the GUI, FreeCAD rebinds Python's + # stdout to its Report View, so the process pipe stays empty. + : >"$WW_LOG" + "$HERE/fcrun" --gui "$LIB_DIR/live-reload.py" >>"$WW_LOG" 2>&1 & + echo "fclive: watching $(basename "$SCRIPT") (pid $!)" + echo "fclive: log -> $WW_LOG" + echo "fclive: stop -> fcquit $(basename "$SCRIPT")" + # Deliberately no `open -a FreeCAD` to raise the window on macOS: we launch + # the binary directly so it inherits WW_WATCH/WWKIT_LIB, which means + # LaunchServices never registers it as a running app — `open -a` would not + # find it and would start a SECOND instance with an empty document. + # Cmd-Tab to it instead. +else + "$HERE/fcrun" --gui "$LIB_DIR/live-reload.py" +fi diff --git a/plugins-claude/freecad/scripts/fcquit b/plugins-claude/freecad/scripts/fcquit new file mode 100755 index 0000000..46a8d20 --- /dev/null +++ b/plugins-claude/freecad/scripts/fcquit @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# fcquit — stop a live FreeCAD session cleanly. Never kill -9. +# +# Drops a stop file that the live-reload watcher polls for; the watcher closes +# its documents via the API (which does not prompt) and then closes the window. +# +# A hard kill leaves recovery breadcrumbs behind, and FreeCAD greets the next +# launch with a modal "Original file corrupted" dialog that blocks every +# automated run after it. Hence this. +# +# Usage: +# fcquit model.py # stop the session watching model.py +# fcquit --all # stop every live session (any stop file we can find) + +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=./fcsession +source "$HERE/fcsession" + +running() { _fc_running; } # see fcsession: name varies by platform/packaging + +wait_gone() { + local n=0 + while running && [[ $n -lt 30 ]]; do + sleep 1 + n=$((n + 1)) + done + if running; then + echo "fcquit: FreeCAD still up after 30s — it may be showing a modal dialog." >&2 + echo "fcquit: close it by hand (Cmd-Q on macOS, Ctrl-Q on Linux)." >&2 + echo "fcquit: do not kill it — that poisons the next launch." >&2 + return 1 + fi + echo "fcquit: closed cleanly." +} + +if [[ "${1:-}" == "--all" ]]; then + if ! running; then + echo "fcquit: nothing running." + exit 0 + fi + shopt -s nullglob + for f in "${TMPDIR:-/tmp}"/fclive-*.stop "${TMPDIR:-/tmp}"/fclive-*.log; do + [[ "$f" == *.log ]] && continue + : >"$f" + done + # Also cover sessions whose stop file was never created. + for l in "${TMPDIR:-/tmp}"/fclive-*.log; do + : >"${l%.log}.stop" + done + wait_gone + exit $? +fi + +SCRIPT="${1:-}" +if [[ -z "$SCRIPT" ]]; then + echo "fcquit: usage: fcquit | fcquit --all" >&2 + exit 2 +fi + +_fcsession_paths "$SCRIPT" + +if ! running; then + echo "fcquit: nothing running." + rm -f "$WW_STOP" + exit 0 +fi + +: >"$WW_STOP" +echo "fcquit: stop requested ($WW_STOP)" +wait_gone diff --git a/plugins-claude/freecad/scripts/fcrender b/plugins-claude/freecad/scripts/fcrender new file mode 100755 index 0000000..46e4a34 --- /dev/null +++ b/plugins-claude/freecad/scripts/fcrender @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# fcrender — render an .FCStd to PNGs so the agent can see its own geometry. +# +# Capture needs a GL context, so this briefly opens the FreeCAD window and +# closes it. There is no headless render path. +# +# Usage: +# fcrender model.FCStd # iso,front,top,right -> ./renders/ +# fcrender model.FCStd out/ # into out/ +# fcrender model.FCStd out/ iso,top # pick views +# +# Views: iso axo front rear top bottom left right + +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +LIB_DIR="$(cd "$HERE/../lib" && pwd)" + +DOC="${1:-}" +if [[ -z "$DOC" || ! -f "$DOC" ]]; then + echo "fcrender: usage: fcrender [out_dir] [views]" >&2 + exit 2 +fi + +OUT="${2:-renders}" +VIEWS="${3:-iso,front,top,right}" +mkdir -p "$OUT" + +LOG="${FCRENDER_LOG:-${TMPDIR:-/tmp}/fcrender.log}" +: >"$LOG" + +# 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 diff --git a/plugins-claude/freecad/scripts/fcrun b/plugins-claude/freecad/scripts/fcrun new file mode 100755 index 0000000..023e147 --- /dev/null +++ b/plugins-claude/freecad/scripts/fcrun @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# fcrun — run a Python script inside FreeCAD, headless by default. +# +# FreeCAD's launcher dumps the whole environment to stdout and complains about +# a missing 3Dconnexion SpaceMouse driver on every start; both are filtered. +# It also exits 0 when a script raises, so we scan for the traceback banner and +# fail loudly instead — a silent abort leaves stale exports that look current. +# +# Usage: +# fcrun model.py # headless (no GUI); validation + exports +# fcrun --gui model.py # with GUI, so view state is written to the doc +# fcrun --bin # print the resolved FreeCAD binary and exit + +set -euo pipefail + +LIB_DIR="$(cd "$(dirname "$0")/../lib" && pwd)" + +find_freecad() { + if [[ -n "${FREECAD_BIN:-}" ]]; then + echo "$FREECAD_BIN" + return + fi + local c + for c in \ + /Applications/FreeCAD.app/Contents/MacOS/FreeCAD \ + "$HOME/Applications/FreeCAD.app/Contents/MacOS/FreeCAD"; do + [[ -x "$c" ]] && { + echo "$c" + return + } + done + for c in freecad FreeCAD freecadcmd FreeCADCmd; do + command -v "$c" >/dev/null 2>&1 && { + command -v "$c" + return + } + done + if command -v flatpak >/dev/null 2>&1 && + flatpak info org.freecad.FreeCAD >/dev/null 2>&1; then + echo "flatpak-run" + return + fi + echo "" # not found +} + +FC="$(find_freecad)" +if [[ -z "$FC" ]]; then + echo "fcrun: FreeCAD not found." >&2 + echo " macOS: brew install --cask freecad" >&2 + echo " Linux: your package manager, or flatpak install org.freecad.FreeCAD" >&2 + echo " Or set FREECAD_BIN to the executable." >&2 + exit 127 +fi + +XVFB=() + +run_fc() { + # Flatpak passes caller env vars through (it strips only a fixed blocklist), + # and Flathub's FreeCAD grants --filesystem=host, so WWKIT_LIB/WW_OUT and the + # model script are all reachable from inside the sandbox. If a user has + # revoked that permission, FreeCAD fails deep inside with a confusing + # traceback rather than a clear error — hence the warning below. + if [[ "$FC" == "flatpak-run" ]]; then + "${XVFB[@]}" flatpak run org.freecad.FreeCAD "$@" + else + "${XVFB[@]}" "$FC" "$@" + fi +} + +flatpak_preflight() { + [[ "$FC" == "flatpak-run" ]] || return 0 + if ! flatpak info --show-permissions org.freecad.FreeCAD 2>/dev/null | + grep -q 'filesystem=host'; then + echo "fcrun: warning — the FreeCAD Flatpak lacks filesystem=host." >&2 + echo "fcrun: it will not be able to read your model script or wwkit." >&2 + echo "fcrun: fix: flatpak override --user --filesystem=host org.freecad.FreeCAD" >&2 + fi +} + +if [[ "${1:-}" == "--bin" ]]; then + echo "$FC" + exit 0 +fi + +GUI=0 +if [[ "${1:-}" == "--gui" ]]; then + GUI=1 + shift + # A --gui run is a one-shot build that exists to bake view state into the + # document, so wwkit closes the window when it finishes. Without this we leak + # a FreeCAD instance that no watcher listens to, which fcquit cannot close and + # only a hard kill can — and a hard kill poisons the next launch. + # fclive runs a session, not a build, and clears this. + # `-` not `:-`: fclive deliberately sets this to the EMPTY string to opt out, + # and `:-` would substitute the default for empty as well as unset, shutting + # the live session down the moment its first build finished. + export WW_GUI_ONESHOT="${WW_GUI_ONESHOT-1}" +fi + +SCRIPT="${1:-}" +if [[ -z "$SCRIPT" || ! -f "$SCRIPT" ]]; then + echo "fcrun: usage: fcrun [--gui] " >&2 + exit 2 +fi +# Absolutise: `flatpak run` does not reliably carry the caller's working +# directory into the sandbox, so a relative path that works natively silently +# fails to resolve once FreeCAD is a Flatpak process. +SCRIPT="$(cd "$(dirname "$SCRIPT")" && pwd)/$(basename "$SCRIPT")" + +# A GUI run needs a display. On macOS that is a given; on Linux this tool may +# well be running over SSH or in a container, where Qt would otherwise die with +# an opaque error buried in a log file nobody is reading. +if [[ "$GUI" -eq 1 && "$(uname)" != "Darwin" ]]; then + if [[ -z "${DISPLAY:-}" && -z "${WAYLAND_DISPLAY:-}" ]]; then + if command -v xvfb-run >/dev/null 2>&1; then + echo "fcrun: no display; running under xvfb-run" >&2 + XVFB=(xvfb-run -a) + else + echo "fcrun: no display (DISPLAY/WAYLAND_DISPLAY unset) and xvfb-run not found." >&2 + echo "fcrun: GUI modes (--gui, fclive, fcrender) need a display." >&2 + echo "fcrun: install xvfb, or run where a desktop session exists." >&2 + exit 4 + fi + fi +fi + +# wwkit lives beside this script; model scripts pick it up from here. +export WWKIT_LIB="$LIB_DIR" + +# Python block-buffers stdout when it is a pipe rather than a tty. For a +# long-running watcher (fclive) that means output never appears at all — it +# sits in the buffer until a process exit that never comes. Force it through. +export PYTHONUNBUFFERED=1 + +# Drop the env dump, the SpaceMouse noise, the launcher line, the console +# banner, and the tab-separated "(20 %) (40 %) ..." progress spew that FreeCAD +# emits for every recompute and topology check. Keep what the script printed. +# +# The progress filter keys on "(NN %)" — digits, space, percent, close-paren — +# so it can't eat a line like "300 g total (30% of a 1kg spool)". +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:]]*$' +} + +# BSD mktemp accepts `-t prefix`; GNU mktemp treats the argument as a template +# and rejects it for having too few X's. Spell the template out — it is the one +# form both agree on. +OUT_FILE="$(mktemp "${TMPDIR:-/tmp}/fcrun.XXXXXX")" +trap 'rm -f "$OUT_FILE"' EXIT + +flatpak_preflight + +if [[ "$GUI" -eq 1 ]]; then + run_fc "$SCRIPT" 2>&1 | tee "$OUT_FILE" | filter || true +else + # `-c` does not exit when the script ends — it drops into an interactive + # Python console and waits for EOF ("Use Ctrl-D to exit"). Inherit a live + # stdin and the process sits there forever. &1 | tee "$OUT_FILE" | filter || true +fi + +# FreeCAD swallows script errors and still exits 0 — catch them ourselves. +if grep -qa "Exception while processing file\|Traceback (most recent call last)" "$OUT_FILE"; then + echo "fcrun: script raised inside FreeCAD — exports may be stale." >&2 + grep -a -A5 "Exception while processing file\|Traceback (most recent call last)" "$OUT_FILE" >&2 || true + exit 1 +fi diff --git a/plugins-claude/freecad/scripts/fcsession b/plugins-claude/freecad/scripts/fcsession new file mode 100755 index 0000000..1327ce6 --- /dev/null +++ b/plugins-claude/freecad/scripts/fcsession @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# fcsession — resolve the control-file paths for a live session. +# +# Sourced by fclive, fcquit and fcsnap so they all agree on where the control +# files live for a given model script. Sets: WW_LOG, WW_STOP, WW_SNAP. + +_fcsession_paths() { + local script="$1" + local base + base="$(basename "${script%.py}")" + # TMPDIR is set on macOS and usually unset on Linux. + local dir="${TMPDIR:-/tmp}" + : "${WW_LOG:=${dir%/}/fclive-${base}.log}" + : "${WW_STOP:=${dir%/}/fclive-${base}.stop}" + : "${WW_SNAP:=${dir%/}/fclive-${base}.snap}" + export WW_LOG WW_STOP WW_SNAP +} + +# Is a FreeCAD instance up? +# +# The executable's name varies by platform and packaging: `freecad` on macOS +# Homebrew and most Linux distros, `FreeCAD` on some, plus AppImage and Flatpak +# builds. Match any of them by name, and fall back to a full-commandline match +# for AppImages (whose process name is the .AppImage file itself). +# +# Deliberately NOT `pgrep -f` on a path substring: that also matches our own +# wrapper shells, whose argv contains the plugin's freecad/ directory, and would +# report a session that isn't there. +_fc_running() { + local n + for n in freecad FreeCAD freecad-real FreeCADCmd freecadcmd; do + pgrep -x "$n" >/dev/null 2>&1 && return 0 + done + pgrep -f '[Ff]ree[Cc][Aa][Dd].*\.AppImage' >/dev/null 2>&1 && return 0 + return 1 +} diff --git a/plugins-claude/freecad/scripts/fcsnap b/plugins-claude/freecad/scripts/fcsnap new file mode 100755 index 0000000..86d9058 --- /dev/null +++ b/plugins-claude/freecad/scripts/fcsnap @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# fcsnap — read the user's live FreeCAD session back into the conversation. +# +# Without this the loop only runs one way: the agent pushes geometry, and the +# human's manipulation is invisible to it and destroyed by the next rebuild. +# +# Emits, into : +# .snap.png the user's ACTUAL camera, not a canned view +# snapshot.json { selected: [...], moved: [{name, delta_mm, delta_yaw_deg}] } +# +# `moved` is the payload that matters: it diffs each part's live placement +# against where the script put it (recorded by wwkit in .intent.json), so a +# drag becomes a concrete proposal to fold back into the script. +# +# Usage: +# fcsnap model.py [out_dir] + +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=./fcsession +source "$HERE/fcsession" + +SCRIPT="${1:-}" +if [[ -z "$SCRIPT" || ! -f "$SCRIPT" ]]; then + echo "fcsnap: usage: fcsnap [out_dir]" >&2 + exit 2 +fi + +if ! _fc_running; then + echo "fcsnap: no live session — start one with: fclive -b $(basename "$SCRIPT")" >&2 + exit 3 +fi + +OUT="${2:-$(cd "$(dirname "$SCRIPT")" && pwd)}" +mkdir -p "$OUT" +OUT="$(cd "$OUT" && pwd)" + +_fcsession_paths "$SCRIPT" + +rm -f "$OUT/snapshot.json" +echo "$OUT" >"$WW_SNAP" + +# The watcher polls once a second; give it a few beats to answer. +for _ in $(seq 1 20); do + [[ -f "$OUT/snapshot.json" ]] && break + sleep 1 +done + +if [[ ! -f "$OUT/snapshot.json" ]]; then + rm -f "$WW_SNAP" + echo "fcsnap: session did not respond — is the watcher running? (see $WW_LOG)" >&2 + exit 1 +fi + +cat "$OUT/snapshot.json" diff --git a/plugins-claude/freecad/scripts/hook-compat.sh b/plugins-claude/freecad/scripts/hook-compat.sh new file mode 100644 index 0000000..2690b6b --- /dev/null +++ b/plugins-claude/freecad/scripts/hook-compat.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# hook-compat.sh — Normalize Claude Code / Copilot CLI hook payloads. +# +# Source this file after reading stdin into HOOK_INPUT: +# +# HOOK_INPUT=$(cat) +# # shellcheck source=scripts/hook-compat.sh +# source "$(dirname "$0")/hook-compat.sh" +# +# Exports (always set, may be empty): +# HOOK_FORMAT — "claude" or "copilot" +# HOOK_TOOL_NAME — PascalCase tool name (e.g. "Bash", "Edit", "Write") +# HOOK_COMMAND — bash command for PreToolUse/Bash hooks +# HOOK_FILE_PATH — file path for PostToolUse/Edit/Write hooks +# HOOK_EVENT_NAME — hook event (e.g. "UserPromptSubmit", "Stop") +# HOOK_PERMISSION_MODE — "default" or "bypassPermissions" +# Claude Code: from payload field permission_mode +# Copilot CLI: from COPILOT_ALLOW_ALL env var (--allow-all / --yolo) +# +# Functions (PreToolUse hooks only): +# hook_ask REASON — output "ask" decision (Claude) / "deny" (Copilot — no "ask" equivalent) +# hook_allow REASON — output "allow" decision +# hook_deny REASON — output hard "deny" decision on both CLIs (always blocks) + +# --- Format detection --- +if echo "$HOOK_INPUT" | jq -e '.toolName' >/dev/null 2>&1; then + HOOK_FORMAT="copilot" +else + HOOK_FORMAT="claude" +fi + +# --- Tool name (normalized to PascalCase) --- +if [[ "$HOOK_FORMAT" == "copilot" ]]; then + _hook_raw_tool=$(echo "$HOOK_INPUT" | jq -r '.toolName // empty') + HOOK_TOOL_NAME="${_hook_raw_tool^}" +else + HOOK_TOOL_NAME=$(echo "$HOOK_INPUT" | jq -r '.tool_name // empty') +fi + +# --- Bash command (PreToolUse) --- +if [[ "$HOOK_FORMAT" == "copilot" ]]; then + HOOK_COMMAND=$(echo "$HOOK_INPUT" | jq -r 'try (.toolArgs | fromjson | .command) catch ""' 2>/dev/null || echo "") +else + HOOK_COMMAND=$(echo "$HOOK_INPUT" | jq -r '.tool_input.command // empty') +fi + +# --- File path (PostToolUse) --- +if [[ "$HOOK_FORMAT" == "copilot" ]]; then + HOOK_FILE_PATH=$(echo "$HOOK_INPUT" | jq -r 'try (.toolArgs | fromjson | .file_path) catch ""' 2>/dev/null || echo "") +else + HOOK_FILE_PATH=$(echo "$HOOK_INPUT" | jq -r '.tool_input.file_path // empty') +fi + +# --- Hook event name --- +HOOK_EVENT_NAME=$(echo "$HOOK_INPUT" | jq -r '.hook_event_name // empty') +# Copilot CLI omits hook_event_name; hooks.json entries pass it via env var instead +if [[ -z "$HOOK_EVENT_NAME" ]] && [[ -n "${HOOK_EVENT_OVERRIDE:-}" ]]; then + HOOK_EVENT_NAME="$HOOK_EVENT_OVERRIDE" +fi + +# --- Permission mode --- +# Unified detection of "allow all" mode across both CLIs. +if [[ "$HOOK_FORMAT" == "copilot" ]]; then + if [[ "${COPILOT_ALLOW_ALL:-}" == "true" ]]; then + HOOK_PERMISSION_MODE="bypassPermissions" + else + HOOK_PERMISSION_MODE="default" + fi +else + HOOK_PERMISSION_MODE=$(echo "$HOOK_INPUT" | jq -r '.permission_mode // "default"') +fi + +# --- Permission decision helpers (PreToolUse hooks) --- + +# Output an "ask" decision (Claude Code prompts the user) or "deny" (Copilot CLI — no "ask" +# equivalent; user must run the command manually if intended) +hook_ask() { + local reason="$1" + if [[ "$HOOK_FORMAT" == "copilot" ]]; then + jq -n --arg r "$reason" '{"permissionDecision":"deny","permissionDecisionReason":$r}' + else + jq -n --arg r "$reason" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"ask",permissionDecisionReason:$r}}' + fi +} + +# Output an "allow" decision +hook_allow() { + local reason="$1" + if [[ "$HOOK_FORMAT" == "copilot" ]]; then + jq -n --arg r "$reason" '{"permissionDecision":"allow","permissionDecisionReason":$r}' + else + jq -n --arg r "$reason" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",permissionDecisionReason:$r}}' + fi +} + +# Output a hard "deny" decision — blocks on both CLIs with no override path. +# Use for genuinely destructive patterns (redirection, find -delete, etc.). +hook_deny() { + local reason="$1" + if [[ "$HOOK_FORMAT" == "copilot" ]]; then + jq -n --arg r "$reason" '{"permissionDecision":"deny","permissionDecisionReason":$r}' + else + jq -n --arg r "$reason" '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$r}}' + fi +} diff --git a/plugins-claude/freecad/skills/live/SKILL.md b/plugins-claude/freecad/skills/live/SKILL.md new file mode 100644 index 0000000..447836b --- /dev/null +++ b/plugins-claude/freecad/skills/live/SKILL.md @@ -0,0 +1,63 @@ +--- +name: freecad-live +disable-model-invocation: true +allowed-tools: Bash, Read, Write, Edit +description: >- + Start or stop a live FreeCAD design session — the model rebuilds in the open + window as the script changes, with the camera left where you put it. +--- + +# Live FreeCAD session + +Argument: a model script (`.py`). With no argument, find the most recently +modified model script under the working directory and confirm it with the user +before starting. + +## Start + +```bash +${CLAUDE_PLUGIN_ROOT}/scripts/fclive -b +``` + +Then wait for the first build and report the result: + +```bash +until grep -qa "rebuild #1 ok\|rebuild FAILED" "$WW_LOG"; do sleep 2; done +``` + +`fclive` prints the log path. Read it for build output — under the GUI, FreeCAD +sends `print()` to its Report View rather than stdout, so the log file is the +only channel that works. + +On macOS the window opens unfocused and cannot be raised programmatically +(the binary is launched directly so it inherits the session's environment, which +means LaunchServices never registers it). Tell the user to Cmd-Tab to it. + +## Iterate + +Edit the model script. The watcher rebuilds within a second, preserving the +user's camera. Do not restart FreeCAD to pick up a change — including changes to +`wwkit` itself, which the watcher reloads. + +Before handing a change back, render it and look at it: + +```bash +${CLAUDE_PLUGIN_ROOT}/scripts/fcrender .FCStd renders/ iso,top +``` + +## Stop + +```bash +${CLAUDE_PLUGIN_ROOT}/scripts/fcquit +``` + +Never `kill` FreeCAD. A hard kill leaves recovery state that blocks the next +launch behind a modal dialog you cannot see. + +## If navigation feels broken + +FreeCAD ships with three-button-mouse bindings that are unusable on a trackpad. +Tell the user to set the navigation style to **Gesture** via the dropdown in the +status bar (bottom right, shows `CAD` by default): left-drag rotates, two-finger +drag pans, scroll zooms. Spacebar toggles visibility of the tree selection, +which is how you isolate a part. diff --git a/plugins-claude/freecad/skills/modeling/SKILL.md b/plugins-claude/freecad/skills/modeling/SKILL.md new file mode 100644 index 0000000..98ceb36 --- /dev/null +++ b/plugins-claude/freecad/skills/modeling/SKILL.md @@ -0,0 +1,169 @@ +--- +name: freecad-modeling +user-invocable: false +allowed-tools: Bash, Read, Write, Edit +description: >- + Script parametric FreeCAD models (woodworking, 3D printing, mixed + wood-and-printed assemblies) with a live GUI the user steers. Use when + designing or editing a FreeCAD model, a .FCStd, a printable part, furniture, + cabinets, jigs, or a workshop layout — and whenever the user wants to see and + manipulate a model rather than only read numbers. +--- + +# Scripting FreeCAD for a human who is watching + +The user describes a build. You write the model as a Python script. FreeCAD +renders it and they pan, rotate, isolate parts, and tell you what is wrong. You +edit the script; the model updates under their cursor with the camera where they +left it. **The script is the source of truth — the `.FCStd` is a build artefact, +disposable and regenerable.** Never hand-edit a `.FCStd`. + +## Tools + +| Command | Purpose | +|---|---| +| `${CLAUDE_PLUGIN_ROOT}/scripts/fcrun model.py` | Headless build: validation + exports, no GUI. Fast. Use while iterating alone. | +| `${CLAUDE_PLUGIN_ROOT}/scripts/fclive -b model.py` | Start the live session the user watches. Leave it running. | +| `${CLAUDE_PLUGIN_ROOT}/scripts/fcquit model.py` | Stop it cleanly. **Always use this — never `kill`.** | +| `${CLAUDE_PLUGIN_ROOT}/scripts/fcsnap model.py` | Read the live session back: the user's camera, selection, and drags. | +| `${CLAUDE_PLUGIN_ROOT}/scripts/fcrender doc.FCStd out/ iso,top,front` | Render PNGs, then `Read` them. This is how you see your own geometry. | + +Look at your own renders before asking the user to look. Catching your own +mistake costs a tool call; making them catch it costs their attention. + +## The loop runs both ways + +`fcsnap` is how the user's manipulation reaches you. It returns a PNG of *their* +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 your +script put it. + +**A drag is a proposal, not a mistake.** When `moved` is non-empty, the user has +told you something with the mouse. Fold it into the script as a real parameter +and say what you changed. Do not silently ignore it — the next rebuild destroys +it. + +## 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 always mm + +m = ww.Model("shelf-unit") # closes any same-named doc first +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() # two parts sharing volume will not fit +m.envelope() # does it fit through the door? +m.cutlist() +m.filament() +m.finish(os.environ.get("WW_OUT", os.getcwd())) +``` + +Put every number worth arguing about in a parameter block at the top. That block +is the interface the user actually edits. + +### Nominal sizes are lies — this is the one that bites + +A "2x4" is 1.5" x 3.5". "3/4" plywood is 23/32". **Never type the nominal number +as a dimension.** Use `ww.LUMBER`, `ww.PLY`, `ww.MDF`, `ww.BALTIC`, or the +`board()` / `panel()` constructors, which take the nominal *name* and produce +actual geometry. `ww.inch()` accepts `3.5`, `"3/4"`, `"1 1/2"`. + +### Units: metric shop, imperial lumberyard + +This user works in **metric** and buys in **imperial**. Default to +`ww.UNITS = "both"` for anything headed to the shop — it prints `914.4 (36")`. +Geometry is always mm. Always refer to stock by its nominal name (a **2x4**, +never a 38x89). + +### cutplan(), not just cutlist() + +`cutlist()` says what parts are needed. `cutplan()` says what to **buy** and what +to **cut from each board** — that is the useful artefact. It only plans parts made +with `board()` or `panel()`; a part made with `box()` has no stock, so there is +nothing to buy it from, and it is reported as skipped. Prefer `board()`/`panel()` +for anything real. + +Rotation of sheet parts is off by default because **grain runs the length of a +sheet**. Do not enable it to improve yield unless the material has no grain. + +**This user drives a Tacoma (5ft bed) and prefers half sheets.** Default to +`m.cutplan(max_length=ww.ft(8), sheet_piece="half")` unless told otherwise — a +plan that buys a 12ft board or a full 4x8 is a plan he cannot get home. He always +buys a full sheet and has the store cut it; `sheet_piece` says how. + +### Joinery + +`trench(part, face, along, pos, width, depth)` is the primitive: a channel cut +into a named face (`+x`, `-z`, ...), 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()`, `fillet()`, `chamfer()`. + +Orientation is explicit: `board()` takes `length_axis` **and** `thickness_axis`, +because one axis is ambiguous — a board lying flat and the same board on edge +share a length axis and are not the same part. + +### Select edges by geometry, never by index + +`Edge7` is renumbered by a recompute — that is FreeCAD's topological naming +problem. Use `vertical_edges`, `edges_at`, `edges_where`. A predicate cannot be +renumbered, so a scripted model simply does not have FreeCAD's worst bug. + +### Always run check_clashes() + +Two parts sharing volume is a part that will not physically fit. It catches +exactly the errors that are invisible in a render — it has already caught real +bugs in the shipped examples. + +## Things FreeCAD will do to you + +These are not hypotheticals; each one has already cost a debugging session. + +**Document names are sanitised.** `newDocument("bracket-box")` yields a document +named `bracket_box`. Any lookup against the unsanitised name silently misses — +which, in a live-reload loop, means every rebuild stacks *another* document +(`bracket_box`, `bracket_box1`, ...) instead of replacing it. `ww.Model` handles +this; if you call `App.newDocument` yourself, sanitise to `[A-Za-z0-9_]`. + +**Headless documents open invisible.** With no GUI, `obj.ViewObject` is `None` — +no view providers exist, so a headless-authored `.FCStd` opens with everything +hidden and the 3D view looks empty. Colour and visibility can only be set when +`App.GuiUp`. `ww.Model.finish()` does this correctly. + +**Under the GUI, `print()` does not reach stdout.** FreeCAD rebinds Python's +stdout to its Report View widget. A caller watching the process pipe sees +silence forever. Use `ww.say()`, which also appends to `$WW_LOG`. + +**Non-ASCII in output aborts the script.** An em-dash in a `print()` raises +inside FreeCAD's console and kills the run *after* printing results but *before* +exporting — leaving stale artefacts that look current. Keep script output ASCII; +`ww.say()` enforces this. + +**`Part::Cut` returns a Compound, not a Solid.** `shape.ShapeType != "Solid"` +means nothing about printability. Judge from the mesh: closed, manifold, no +self-intersections. That is what `m.check_printable()` reports. + +**Never kill FreeCAD.** A hard kill leaves recovery breadcrumbs, and the next +launch greets you with a modal "Original file corrupted" dialog that blocks every +automated run behind it — invisibly, because you cannot see the dialog. Use +`fcquit`. Also note we launch the binary directly (so it inherits the env), which +means macOS never registers it with LaunchServices: `open -a FreeCAD` would spawn +a *second* instance, and AppleScript cannot address the running one. + +**Camera moves are animated.** Capturing an image straight after switching views +photographs the camera mid-flight. `fcrender` settles the event loop first. + +## Working with the user + +Start `fclive -b` once and leave it up for the session. Edit the script; do not +restart FreeCAD to apply a change — the watcher reloads `wwkit` too, so library +edits land live as well. + +Report what changed in terms they care about (panel dimensions, filament grams, +clashes), not in terms of FreeCAD objects. diff --git a/plugins-copilot/freecad/.claude-plugin/plugin.json b/plugins-copilot/freecad/.claude-plugin/plugin.json new file mode 100644 index 0000000..048b699 --- /dev/null +++ b/plugins-copilot/freecad/.claude-plugin/plugin.json @@ -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" + } +} diff --git a/plugins-copilot/freecad/README.md b/plugins-copilot/freecad/README.md new file mode 120000 index 0000000..125a613 --- /dev/null +++ b/plugins-copilot/freecad/README.md @@ -0,0 +1 @@ +../../plugins-claude/freecad/README.md \ No newline at end of file diff --git a/plugins-copilot/freecad/examples b/plugins-copilot/freecad/examples new file mode 120000 index 0000000..45dff5a --- /dev/null +++ b/plugins-copilot/freecad/examples @@ -0,0 +1 @@ +../../plugins-claude/freecad/examples \ No newline at end of file diff --git a/plugins-copilot/freecad/hooks/hooks.json b/plugins-copilot/freecad/hooks/hooks.json new file mode 100644 index 0000000..d56022c --- /dev/null +++ b/plugins-copilot/freecad/hooks/hooks.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "hooks": { + "preToolUse": [ + { + "type": "command", + "bash": "bash ${COPILOT_PLUGIN_ROOT}/scripts/approve-own-scripts.sh" + } + ] + } +} diff --git a/plugins-copilot/freecad/lib b/plugins-copilot/freecad/lib new file mode 120000 index 0000000..9f577fe --- /dev/null +++ b/plugins-copilot/freecad/lib @@ -0,0 +1 @@ +../../plugins-claude/freecad/lib \ No newline at end of file diff --git a/plugins-copilot/freecad/scripts b/plugins-copilot/freecad/scripts new file mode 120000 index 0000000..2913e2b --- /dev/null +++ b/plugins-copilot/freecad/scripts @@ -0,0 +1 @@ +../../plugins-claude/freecad/scripts \ No newline at end of file diff --git a/plugins-copilot/freecad/skills b/plugins-copilot/freecad/skills new file mode 120000 index 0000000..2320edb --- /dev/null +++ b/plugins-copilot/freecad/skills @@ -0,0 +1 @@ +../../plugins-claude/freecad/skills \ No newline at end of file diff --git a/utils/sync.sh b/utils/sync.sh index 9c3a764..dc5bae3 100755 --- a/utils/sync.sh +++ b/utils/sync.sh @@ -26,6 +26,7 @@ declare -A NEEDS=( ["convert-doc"]="approve-own-scripts.sh hook-compat.sh" ["elevated-edit"]="approve-own-scripts.sh hook-compat.sh" ["format-on-save"]="approve-own-scripts.sh hook-compat.sh" + ["freecad"]="approve-own-scripts.sh hook-compat.sh" ["git-tools"]="approve-own-scripts.sh hook-compat.sh git-cli" ["image"]="approve-own-scripts.sh hook-compat.sh" ["java-toolkit"]="approve-own-scripts.sh hook-compat.sh"