Files
solidcraft-upstream/examples/demo/enclosure_ui_mcp.py
T

252 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Demo v2: build an electronics enclosure in a running SolveCraft the way a person would, with
the parametric steps sent over MCP.
solvecraft --control 7878 &
python3 examples/demo/enclosure_ui_mcp.py --port 7878 --cli solvecraft-cli
Modelling steps go through the app's UI over its control channel: the pointer glides to a
toolbar button and clicks it (the real dialog opens), glides to the face or point it picks and
clicks, and values are typed into the dialog, as by hand. Sketches go on the model's faces and
the camera follows like a user's would (Look At on a new sketch, back out to a 3/4 view on
Finish Sketch, a little orbit between features). The parameters and the dimensions that use them
are MCP `execute` calls through `solvecraft-cli mcp --connect` (enclosure_mcp.py's client).
--log writes one JSON line per caption and per pointer event, which record.sh turns into burnt-in
captions and a drawn cursor (the app gets synthetic pointer events, so the screen's own pointer
never moves).
"""
import argparse
import json
import math
import os
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from enclosure_mcp import Mcp, control # noqa: E402
class Demo:
def __init__(self, port, mcp, log, speed):
self.port, self.mcp, self.log, self.speed = port, mcp, log, speed
self.pos = None
self.stop = 10**6
# -- logging -------------------------------------------------------------------------------
def mark(self, **k):
if self.log:
self.log.write(json.dumps({"t": time.time(), **k}) + "\n")
self.log.flush()
def caption(self, text, via=""):
self.n = getattr(self, "n", 0) + 1
if self.n > self.stop:
raise StopIteration
print(f" {text} [{via}]", file=sys.stderr)
self.mark(caption=text, via=via)
def wait(self, s):
time.sleep(s * self.speed)
# -- the app -------------------------------------------------------------------------------
def ui(self, method, params=None):
r = control(self.port, method, params)
if not r.get("ok", False):
raise RuntimeError(f"{method} {params}: {r.get('error')}")
return r.get("result")
def at(self, **where):
p = self.ui("ui.at", where)
return (p[0], p[1])
def glide(self, to, dur=0.35):
"""Move the pointer to `to` along an eased path (the app sees every step: hover
highlights follow it)."""
frm = self.pos or (1150.0, 620.0)
n = max(2, int(dur * 40))
for i in range(1, n + 1):
t = i / n
e = t * t * (3 - 2 * t)
x, y = frm[0] + (to[0] - frm[0]) * e, frm[1] + (to[1] - frm[1]) * e
self.ui("ui.move", {"x": x, "y": y})
self.mark(cursor=[x, y])
time.sleep(dur / n)
self.pos = to
def click(self, to, dur=0.35, settle=0.25):
self.glide(to, dur)
self.ui("ui.click", {"x": to[0], "y": to[1]})
self.mark(cursor=list(to), click=True)
self.wait(settle)
def tool(self, command, panel=None):
"""Click a toolbar button (opening its panel's drop-down first when it's in one)."""
if panel:
self.click(self.at(handle=panel), settle=0.3)
self.click(self.at(handle=f"toolbar:{command}"), settle=0.35)
def type(self, text, enter=True):
self.ui("ui.text", {"text": text})
self.wait(0.35)
if enter:
self.ui("ui.key", {"key": "Enter"})
self.wait(0.5)
def key(self, k):
self.ui("ui.key", {"key": k})
self.wait(0.15)
def view(self, v, settle=0.7):
self.ui("ui.view", {"view": v, "animate": True})
self.wait(settle)
def orbit(self, dx, dy=0, steps=40):
"""A right-button drag in the viewport, as a user orbits."""
vp = self.ui("ui.inspect").get("viewport") or [250, 126, 1350, 800]
cx, cy = vp[0] + vp[2] * 0.62, vp[1] + vp[3] * 0.55
self.glide((cx, cy), 0.25)
self.mark(drag_start=[cx, cy])
self.ui("ui.drag", {"x0": cx, "y0": cy, "x1": cx + dx, "y1": cy + dy, "button": "right", "steps": steps})
self.settle_camera()
# The pointer moved with the camera (the app plays the drag one event a frame).
self.mark(drag_end=[cx + dx, cy + dy])
self.pos = (cx + dx, cy + dy)
def settle_camera(self):
"""Wait until the app has played queued pointer events (the camera stops moving)."""
last, still = None, 0
for _ in range(80):
time.sleep(0.1)
cam = json.dumps(self.ui("ui.inspect").get("camera"))
still = still + 1 if cam == last else 0
last = cam
if still >= 2:
return
def execute(self, command, params):
self.mcp.tool("execute", {"command": command, "params": params})
def run(d):
d.caption("Parameters over MCP: width 80, depth 60, height 30, wall 2", "MCP tools/call execute parameters.change")
for name, expr in [("width", "80 mm"), ("depth", "60 mm"), ("height", "30 mm"), ("wall", "2 mm")]:
d.execute("parameters.change", {"name": name, "expression": expr})
d.wait(0.25)
d.wait(0.6)
d.caption("Create Sketch on the XY plane", "UI click")
d.tool("sketch.create")
d.click(d.at(plane="XY"), settle=0.8)
d.caption("Center rectangle from the origin", "UI click")
d.tool("sketch.rectangle.center")
d.click(d.at(sketch=[0, 0]), settle=0.15)
d.click(d.at(sketch=[40, 30]), dur=0.45, settle=0.2)
d.key("Escape")
d.caption("Constraints over MCP: centred, width × depth", "MCP tools/call execute sketch.dimension")
sk = json.loads(d.mcp.tool("execute", {"command": "sketch.inspect", "params": {}}))
sk = sk.get("result", sk)
centre = next((p["id"] for p in sk.get("points", []) if p["id"] != "origin" and abs(p["at"][0]) + abs(p["at"][1]) < 1e-6), None)
if centre:
d.execute("sketch.constraint.coincident", {"a": centre, "b": "origin"})
d.execute("sketch.dimension", {"entities": ["l1"], "value": "width"})
d.wait(0.3)
d.execute("sketch.dimension", {"entities": ["l2"], "value": "depth"})
d.wait(0.8)
d.caption("Finish Sketch", "UI click")
d.tool("sketch.finish")
# The origin planes are in the way of picking faces from here on.
d.ui("ui.set", {"showOrigin": False})
d.view("home", 0.6)
d.caption("Extrude: height", "UI click")
d.tool("solid.extrude")
d.wait(0.3)
d.type("height")
d.caption("Fillet the four vertical edges: 6 mm", "UI click")
d.tool("solid.fillet")
for p in ([40, -30, 15], [-40, -30, 15], [40, 30, 15]):
d.click(d.at(world=p), settle=0.15)
d.orbit(-260, 20)
d.click(d.at(world=[-40, 30, 15]), settle=0.2)
d.type("6")
d.view("home", 0.5)
d.caption("Shell: open the top face, wall thickness", "UI click")
d.tool("solid.shell", panel="panel:SOLID:MODIFY")
d.click(d.at(world=[0, 0, 30]), settle=0.3)
d.type("wall")
d.caption("Sketch on the side face for a USB port", "UI click")
d.tool("sketch.create")
d.click(d.at(world=[40, 10, 20]), settle=0.9)
d.tool("sketch.rectangle.center")
d.click(d.at(world=[40, 0, 12]), settle=0.15)
d.click(d.at(world=[40, 6, 15.5]), dur=0.4, settle=0.2)
d.key("Escape")
d.tool("sketch.finish")
d.view("home", 0.5)
# Over MCP with the profile named by a point inside it: a profile picked in the Extrude
# dialog is kept by its index, which a face sketch renumbers when the face changes shape.
d.caption("Cut the port through the wall", "MCP tools/call execute solid.extrude")
d.execute("solid.extrude", {"profiles": [{"point": [0, 12]}], "distance": "-wall", "operation": "cut"})
d.wait(0.6)
d.orbit(-140, 30)
d.caption("Sketch on the inner floor: four standoffs", "UI click")
d.tool("sketch.create")
d.click(d.at(world=[6, -9, 2]), settle=0.9)
d.tool("sketch.circle.center")
for x, y in [(-30, -20), (30, -20), (30, 20), (-30, 20)]:
d.click(d.at(world=[x, y, 2]), dur=0.3, settle=0.1)
d.click(d.at(world=[x + 3.5, y, 2]), dur=0.2, settle=0.15)
d.key("Escape")
d.tool("sketch.finish")
d.view("home", 0.5)
d.caption("Extrude the standoffs: 12 mm", "MCP tools/call execute solid.extrude")
d.execute("solid.extrude", {"profiles": [{"point": [x, y]} for x, y in [(-30, -20), (30, -20), (30, 20), (-30, 20)]], "distance": 12, "operation": "join"})
d.wait(0.4)
d.caption("Change one parameter: width 80 → 100 mm", "MCP tools/call execute parameters.change")
d.execute("parameters.change", {"name": "width", "expression": "100 mm"})
d.wait(1.8)
d.caption("", "")
d.orbit(420, 0, steps=90)
d.view("home", 1.5)
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("--log", help="write a JSON line per caption and pointer event")
ap.add_argument("--until", type=int, default=10**6, help="stop before step N (trying the script out)")
ap.add_argument("--speed", type=float, default=1.0, help="scale the pauses (2 = twice as long)")
a = ap.parse_args()
mcp = Mcp([a.cli, "mcp", "--connect", f"127.0.0.1:{a.port}"])
mcp.send("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "enclosure-demo-v2", "version": "2"}})
mcp.send("notifications/initialized", notify=True)
d = Demo(a.port, mcp, open(a.log, "w") if a.log else None, a.speed)
d.stop = a.until - 1
t0 = time.time()
try:
run(d)
except StopIteration:
pass
d.mark(caption="", via="")
mcp.close()
print(f"done in {time.time() - t0:.1f}s", file=sys.stderr)
if __name__ == "__main__":
main()