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"