#!/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()