MCP demo: examples/demo/enclosure_mcp.py builds an electronics enclosure in the running app over MCP, one execute call per step of enclosure_steps.json (rounded box, shell, USB port cut, four screw bosses, then width 80 -> 100 mm rebuilt), ending on an orbit; record.sh and captions.py record it with ffmpeg and burn in captions
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
517b7a911c
commit
025380277a
5 files changed
+477
No files matched your search
@@ -72,6 +72,15 @@ result with `isError: true` and the engine's message, which the model can read a
|
||||
`batch` rollback undoes as many steps as the batch added to the undo history; batches that
|
||||
themselves call `edit.undo`, `file.new` or `doc.open` are not rolled back exactly.
|
||||
|
||||
## Demo: an enclosure built over MCP
|
||||
|
||||
`examples/demo/enclosure_mcp.py` is a small MCP client. It drives a running app (`solvecraft
|
||||
--control PORT`) through `solvecraft-cli mcp --connect` and builds an electronics enclosure one
|
||||
`execute` call at a time (`enclosure_steps.json`): a rounded box, shelled open at the top, a
|
||||
USB port cut through a side wall, four screw bosses, and then a change of the `width`
|
||||
parameter that the timeline rebuilds. `examples/demo/record.sh` records the run with ffmpeg,
|
||||
with each step's caption burnt in.
|
||||
|
||||
## A typical session
|
||||
|
||||
```text
|
||||
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
"""captions.py LOG START OUT.ass: the step log of enclosure_mcp.py --log as ASS captions
|
||||
(top centre: the step, under it the MCP call), timed from START (the recording's epoch)."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
log, start, out = sys.argv[1], float(sys.argv[2]), sys.argv[3]
|
||||
marks = [json.loads(line) for line in open(log) if line.strip()]
|
||||
head = """[Script Info]
|
||||
ScriptType: v4.00+
|
||||
PlayResX: 1920
|
||||
PlayResY: 1080
|
||||
|
||||
[V4+ Styles]
|
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||
Style: Step,DejaVu Sans,34,&H00FFFFFF,&H00FFFFFF,&H00000000,&H80000000,1,0,0,0,100,100,0,0,3,10,0,8,0,0,150,1
|
||||
Style: Call,DejaVu Sans Mono,22,&H00A0E0FF,&H00FFFFFF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,3,8,0,8,0,0,206,1
|
||||
|
||||
[Events]
|
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||
"""
|
||||
|
||||
|
||||
def ts(t):
|
||||
t = max(t, 0.0)
|
||||
return f"{int(t // 3600)}:{int(t % 3600 // 60):02}:{t % 60:05.2f}"
|
||||
|
||||
|
||||
lines = []
|
||||
for i, m in enumerate(marks):
|
||||
if not m["caption"]:
|
||||
continue
|
||||
a = m["t"] - start
|
||||
b = (marks[i + 1]["t"] - start) if i + 1 < len(marks) else a + 3
|
||||
lines.append(f"Dialogue: 0,{ts(a)},{ts(b)},Step,,0,0,0,,{m['caption']}")
|
||||
if m.get("command"):
|
||||
lines.append(f"Dialogue: 0,{ts(a)},{ts(b)},Call,,0,0,0,,MCP tools/call execute {m['command']}")
|
||||
open(out, "w").write(head + "\n".join(lines) + "\n")
|
||||
Executable
+139
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build an electronics enclosure in a running SolveCraft over MCP, one command at a time.
|
||||
|
||||
solvecraft --control 7878 &
|
||||
python3 examples/demo/enclosure_mcp.py --port 7878
|
||||
|
||||
The script is a minimal MCP client: it starts `solvecraft-cli mcp --connect 127.0.0.1:PORT`
|
||||
(stdio, JSON-RPC 2.0, one message per line), initializes, then sends one `execute` tool call
|
||||
per step of enclosure_steps.json, waiting `pause` seconds after each so the timeline can be
|
||||
watched growing. Camera moves (fit, home view, the closing orbit) go over the app's control
|
||||
channel, since they change the view, not the design. With --log it writes one JSON line per
|
||||
step (time, caption) for captions.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
class Mcp:
|
||||
"""A stdio MCP client for `solvecraft-cli mcp`."""
|
||||
|
||||
def __init__(self, argv):
|
||||
self.p = subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, bufsize=1)
|
||||
self.next_id = 0
|
||||
|
||||
def send(self, method, params=None, notify=False):
|
||||
msg = {"jsonrpc": "2.0", "method": method}
|
||||
if params is not None:
|
||||
msg["params"] = params
|
||||
if not notify:
|
||||
self.next_id += 1
|
||||
msg["id"] = self.next_id
|
||||
self.p.stdin.write(json.dumps(msg) + "\n")
|
||||
self.p.stdin.flush()
|
||||
if notify:
|
||||
return None
|
||||
while True:
|
||||
line = self.p.stdout.readline()
|
||||
if not line:
|
||||
raise RuntimeError("the MCP server exited")
|
||||
r = json.loads(line)
|
||||
if r.get("id") == self.next_id:
|
||||
if "error" in r:
|
||||
raise RuntimeError(r["error"])
|
||||
return r["result"]
|
||||
|
||||
def tool(self, name, arguments):
|
||||
r = self.send("tools/call", {"name": name, "arguments": arguments})
|
||||
text = "".join(c.get("text", "") for c in r.get("content", []) if c.get("type") == "text")
|
||||
if r.get("isError"):
|
||||
raise RuntimeError(f"{name}: {text}")
|
||||
return text
|
||||
|
||||
def close(self):
|
||||
self.p.stdin.close()
|
||||
self.p.wait(timeout=10)
|
||||
|
||||
|
||||
def control(port, method, params=None):
|
||||
"""One request on the app's control channel (camera moves)."""
|
||||
with socket.create_connection(("127.0.0.1", port)) as s:
|
||||
s.sendall((json.dumps({"id": 1, "method": method, "params": params or {}}) + "\n").encode())
|
||||
buf = b""
|
||||
while not buf.endswith(b"\n"):
|
||||
chunk = s.recv(1 << 16)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
return json.loads(buf or b"{}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--port", type=int, default=7878, help="the app's --control port")
|
||||
ap.add_argument("--cli", default="solvecraft-cli", help="path to solvecraft-cli")
|
||||
ap.add_argument("--steps", default=os.path.join(HERE, "enclosure_steps.json"))
|
||||
ap.add_argument("--pause", type=float, default=2.5, help="seconds after each step (unless the step says)")
|
||||
ap.add_argument("--log", help="write a JSON line per step: {t, caption, command}")
|
||||
ap.add_argument("--no-orbit", action="store_true", help="skip the closing orbit")
|
||||
a = ap.parse_args()
|
||||
|
||||
steps = json.load(open(a.steps))["steps"]
|
||||
log = open(a.log, "w") if a.log else None
|
||||
t0 = time.time()
|
||||
|
||||
def mark(caption, command=""):
|
||||
if log:
|
||||
log.write(json.dumps({"t": time.time(), "caption": caption, "command": command}) + "\n")
|
||||
log.flush()
|
||||
|
||||
mcp = Mcp([a.cli, "mcp", "--connect", f"127.0.0.1:{a.port}"])
|
||||
init = mcp.send("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "enclosure-demo", "version": "1"}})
|
||||
mcp.send("notifications/initialized", notify=True)
|
||||
print(f"connected to {init.get('serverInfo', {}).get('name')}", file=sys.stderr)
|
||||
|
||||
for i, st in enumerate(steps):
|
||||
if st.get("caption"):
|
||||
mark(st["caption"], st["command"])
|
||||
print(f"[{time.time() - t0:5.1f}s] {i + 1:2}/{len(steps)} {st['command']} {json.dumps(st.get('params', {}))}", file=sys.stderr)
|
||||
mcp.tool("execute", {"command": st["command"], "params": st.get("params", {})})
|
||||
if st.get("view"):
|
||||
control(a.port, "ui.view", {"view": st["view"], "animate": True})
|
||||
time.sleep(st.get("pause", a.pause))
|
||||
|
||||
if not a.no_orbit:
|
||||
# The home view, a slow orbit (a right drag across the viewport), back home to end on.
|
||||
mark("")
|
||||
control(a.port, "ui.view", {"view": "home", "animate": True})
|
||||
time.sleep(1.5)
|
||||
vp = control(a.port, "ui.inspect").get("result", {}).get("viewport") or [250, 126, 1350, 800]
|
||||
cx, cy = vp[0] + vp[2] / 2, vp[1] + vp[3] / 2
|
||||
mark("Orbit")
|
||||
control(a.port, "ui.drag", {"x0": cx - 350, "y0": cy, "x1": cx + 350, "y1": cy + 30, "button": "right", "steps": 240})
|
||||
# The app plays the drag one event per frame: wait until the camera stops turning.
|
||||
last, still = None, 0
|
||||
for _ in range(120):
|
||||
time.sleep(0.5)
|
||||
yaw = control(a.port, "ui.inspect").get("result", {}).get("camera", {}).get("yaw")
|
||||
still = still + 1 if yaw == last else 0
|
||||
last = yaw
|
||||
if still >= 2:
|
||||
break
|
||||
# And back to the iso view to end on.
|
||||
control(a.port, "ui.view", {"view": "home", "animate": True})
|
||||
time.sleep(3)
|
||||
mark("")
|
||||
mcp.close()
|
||||
print(f"done in {time.time() - t0:.1f}s", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,265 @@
|
||||
{
|
||||
"description": "An electronics enclosure built over MCP, one command at a time (examples/demo/enclosure_mcp.py). Each step: an MCP `execute` call; `caption` is shown in the video, `pause` (s) is the wait after it, `view` then animates the app's camera to that view. A lip on the rim is left out until issue #29 (Lip on a rounded rim) is fixed.",
|
||||
"steps": [
|
||||
{
|
||||
"caption": "User parameters: width, depth, height, wall",
|
||||
"command": "parameters.change",
|
||||
"params": {
|
||||
"name": "width",
|
||||
"expression": "80 mm",
|
||||
"comment": "outside, X"
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "",
|
||||
"command": "parameters.change",
|
||||
"params": {
|
||||
"name": "depth",
|
||||
"expression": "60 mm",
|
||||
"comment": "outside, Y"
|
||||
},
|
||||
"pause": 0.6
|
||||
},
|
||||
{
|
||||
"caption": "",
|
||||
"command": "parameters.change",
|
||||
"params": {
|
||||
"name": "height",
|
||||
"expression": "30 mm"
|
||||
},
|
||||
"pause": 0.6
|
||||
},
|
||||
{
|
||||
"caption": "",
|
||||
"command": "parameters.change",
|
||||
"params": {
|
||||
"name": "wall",
|
||||
"expression": "2 mm"
|
||||
},
|
||||
"pause": 0.6
|
||||
},
|
||||
{
|
||||
"caption": "Sketch on XY",
|
||||
"command": "sketch.create",
|
||||
"params": {
|
||||
"plane": "XY",
|
||||
"name": "Outline"
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Center rectangle",
|
||||
"command": "sketch.rectangle.center",
|
||||
"params": {
|
||||
"center": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"corner": [
|
||||
40,
|
||||
30
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Centre it on the origin",
|
||||
"command": "sketch.constraint.coincident",
|
||||
"params": {
|
||||
"a": "p5",
|
||||
"b": "origin"
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Dimension: width",
|
||||
"command": "sketch.dimension",
|
||||
"params": {
|
||||
"entities": [
|
||||
"l1"
|
||||
],
|
||||
"value": "width"
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Dimension: depth",
|
||||
"command": "sketch.dimension",
|
||||
"params": {
|
||||
"entities": [
|
||||
"l2"
|
||||
],
|
||||
"value": "depth"
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Finish sketch",
|
||||
"command": "sketch.finish",
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"caption": "Extrude by height",
|
||||
"command": "solid.extrude",
|
||||
"params": {
|
||||
"distance": "height",
|
||||
"body_name": "Enclosure"
|
||||
},
|
||||
"view": "home"
|
||||
},
|
||||
{
|
||||
"caption": "Round the vertical edges (6 mm)",
|
||||
"command": "solid.fillet",
|
||||
"params": {
|
||||
"edges": [
|
||||
[
|
||||
40,
|
||||
30,
|
||||
15
|
||||
],
|
||||
[
|
||||
-40,
|
||||
30,
|
||||
15
|
||||
],
|
||||
[
|
||||
-40,
|
||||
-30,
|
||||
15
|
||||
],
|
||||
[
|
||||
40,
|
||||
-30,
|
||||
15
|
||||
]
|
||||
],
|
||||
"radius": 6
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Shell it open at the top (wall)",
|
||||
"command": "solid.shell",
|
||||
"params": {
|
||||
"faces": [
|
||||
[
|
||||
0,
|
||||
0,
|
||||
30
|
||||
]
|
||||
],
|
||||
"thickness": "wall"
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Sketch on YZ for the USB port",
|
||||
"command": "sketch.create",
|
||||
"params": {
|
||||
"plane": {
|
||||
"origin": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"x_dir": [
|
||||
0,
|
||||
1,
|
||||
0
|
||||
],
|
||||
"y_dir": [
|
||||
0,
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
"name": "USB port"
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Port outline",
|
||||
"command": "sketch.rectangle.center",
|
||||
"params": {
|
||||
"center": [
|
||||
0,
|
||||
12
|
||||
],
|
||||
"corner": [
|
||||
6,
|
||||
15.5
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Finish sketch",
|
||||
"command": "sketch.finish",
|
||||
"params": {}
|
||||
},
|
||||
{
|
||||
"caption": "Cut through the side wall",
|
||||
"command": "solid.extrude",
|
||||
"params": {
|
||||
"through_all": true,
|
||||
"operation": "cut"
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Screw boss",
|
||||
"command": "plastic.boss",
|
||||
"params": {
|
||||
"position": [
|
||||
-30,
|
||||
-20,
|
||||
2
|
||||
],
|
||||
"diameter": 7,
|
||||
"height": 12,
|
||||
"hole_diameter": 2.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Screw boss",
|
||||
"command": "plastic.boss",
|
||||
"params": {
|
||||
"position": [
|
||||
30,
|
||||
-20,
|
||||
2
|
||||
],
|
||||
"diameter": 7,
|
||||
"height": 12,
|
||||
"hole_diameter": 2.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Screw boss",
|
||||
"command": "plastic.boss",
|
||||
"params": {
|
||||
"position": [
|
||||
30,
|
||||
20,
|
||||
2
|
||||
],
|
||||
"diameter": 7,
|
||||
"height": 12,
|
||||
"hole_diameter": 2.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Screw boss",
|
||||
"command": "plastic.boss",
|
||||
"params": {
|
||||
"position": [
|
||||
-30,
|
||||
20,
|
||||
2
|
||||
],
|
||||
"diameter": 7,
|
||||
"height": 12,
|
||||
"hole_diameter": 2.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"caption": "Change one parameter: width 80 \u2192 100 mm",
|
||||
"command": "parameters.change",
|
||||
"params": {
|
||||
"name": "width",
|
||||
"expression": "100 mm"
|
||||
},
|
||||
"pause": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
# record.sh DISPLAY PORT OUTDIR: record enclosure_mcp.py driving the app on X display DISPLAY
|
||||
# (1920x1080, the app started with --control PORT) to OUTDIR/enclosure-demo.mp4 (H.264,
|
||||
# captions burnt in) and OUTDIR/enclosure-demo.gif. Needs ffmpeg with x11grab and libass.
|
||||
set -e
|
||||
DISP=${1:-:0}; PORT=${2:-7878}; OUT=${3:-.}
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
CLI=${SOLVECRAFT_CLI:-solvecraft-cli}
|
||||
mkdir -p "$OUT"
|
||||
# x11grab stamps frames with the wall clock: its "start:" is when the first frame was taken.
|
||||
ffmpeg -loglevel info -nostats -y -f x11grab -draw_mouse 0 -video_size 1920x1080 -framerate 30 -i "$DISP" \
|
||||
-c:v libx264 -preset veryfast -crf 16 -pix_fmt yuv420p "$OUT/raw.mp4" 2> "$OUT/ffmpeg.log" &
|
||||
FF=$!
|
||||
sleep 2
|
||||
python3 "$HERE/enclosure_mcp.py" --port "$PORT" --cli "$CLI" --log "$OUT/steps.log"
|
||||
sleep 2
|
||||
kill -INT $FF; wait $FF || true
|
||||
START=$(grep -o "start: [0-9.]*" "$OUT/ffmpeg.log" | head -1 | cut -d" " -f2)
|
||||
python3 "$HERE/captions.py" "$OUT/steps.log" "$START" "$OUT/captions.ass"
|
||||
ffmpeg -loglevel error -y -i "$OUT/raw.mp4" -vf "ass=$OUT/captions.ass" \
|
||||
-c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p -movflags +faststart "$OUT/enclosure-demo.mp4"
|
||||
ffmpeg -loglevel error -y -i "$OUT/enclosure-demo.mp4" \
|
||||
-vf "fps=8,scale=720:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=64:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=4" \
|
||||
"$OUT/enclosure-demo.gif"
|
||||
ls -la "$OUT"/enclosure-demo.*
|
||||
Reference in new issue
Block a user