diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 7733aa00..5526f657 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -18,8 +18,8 @@ Generated-in-code assets are original and have no file to list: | File | Author | Source | Licence | |---|---|---|---| | docs/screenshots/sample-plate.png | SolveCraft contributors | screenshot of SolveCraft itself (`solvecraft --sample`) | MIT OR Apache-2.0 | -| docs/screenshots/demo.gif | SolveCraft contributors | original electronics enclosure screen recording of the actual SolveCraft 0.2.0 English desktop UI, generated with examples/demo/record_ui.sh and its original animated pointer overlay (4× playback) | MIT OR Apache-2.0 | -| docs/screenshots/enclosure-demo-0.2.0.mp4 | SolveCraft contributors | original electronics enclosure screen recording of the actual SolveCraft 0.2.0 English desktop UI, generated with examples/demo/record_ui.sh and its original animated pointer overlay (4× playback) | MIT OR Apache-2.0 | +| docs/screenshots/enclosure-demo-0.2.1.gif | SolveCraft contributors | original electronics enclosure screen recording of the actual SolveCraft 0.2.1 English desktop UI, generated with examples/demo/record_ui.sh and its original animated pointer overlay (4× playback) | MIT OR Apache-2.0 | +| docs/screenshots/enclosure-demo-0.2.1.mp4 | SolveCraft contributors | original electronics enclosure screen recording of the actual SolveCraft 0.2.1 English desktop UI, generated with examples/demo/record_ui.sh and its original animated pointer overlay (4× playback) | MIT OR Apache-2.0 | | docs/book/src/generated/sample-plate.png | SolveCraft contributors | copy of docs/screenshots/sample-plate.png made by `cargo xtask book` (gitignored) | MIT OR Apache-2.0 | | docs/book/src/generated/bracket.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot examples/bracket.json`; gitignored) | MIT OR Apache-2.0 | | docs/book/src/generated/enclosure.png | SolveCraft contributors | rendered by `cargo xtask book` (`solvecraft-cli snapshot docs/book/scripts/enclosure.json`; gitignored) | MIT OR Apache-2.0 | diff --git a/README.md b/README.md index 1343af43..f0f51fa5 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ Sketch, constrain, and build solids with a parametric feature timeline. Change a and watch your model rebuild. Run it on your desktop, in your browser, or let AI agents model through the built-in MCP server. Part of the storytold "craft" family. -![SolveCraft 0.2.0: build a rounded electronics enclosure with a USB port and four mounting bosses, then resize it through Parameters](docs/screenshots/demo.gif) +![SolveCraft: draw a rounded electronics enclosure, sketch its USB port directly on a face, add four mounting bosses and resize it through Parameters](docs/screenshots/enclosure-demo-0.2.1.gif) -*An AI agent builds a parametric electronics enclosure in the actual 0.2.0 desktop UI: rounded corners, a shell, a USB port and a four-boss pattern. Change width from 80 → 100 mm and wall thickness from 2 → 3 mm in Parameters and watch it rebuild. Original animated pointer and click cues; 4× playback. [Full video](https://github.com/bherbruck/solvecraft/raw/refs/heads/main/docs/screenshots/enclosure-demo-0.2.0.mp4), generated with the [demo script](examples/demo/record_ui.sh).* +*Start with an empty design and draw the original parametric electronics enclosure in the English desktop UI: rounded corners, a shell, a USB port sketched directly on the wall and a four-boss pattern. Change width from 80 → 100 mm and wall thickness from 2 → 3 mm in Parameters and watch it rebuild. Original animated pointer and click cues; 4× playback. [Full video](https://github.com/bherbruck/solvecraft/raw/refs/heads/main/docs/screenshots/enclosure-demo-0.2.1.mp4), generated with the [demo script](examples/demo/record_ui.sh).* **Documentation:** the [SolveCraft book](https://bherbruck.github.io/solvecraft/) (user guide, developer guide and command reference; sources in [docs/book](docs/book)), and the diff --git a/docs/screenshots/demo.gif b/docs/screenshots/demo.gif deleted file mode 100644 index b6943055..00000000 Binary files a/docs/screenshots/demo.gif and /dev/null differ diff --git a/docs/screenshots/enclosure-demo-0.2.0.mp4 b/docs/screenshots/enclosure-demo-0.2.0.mp4 deleted file mode 100644 index d81ce665..00000000 Binary files a/docs/screenshots/enclosure-demo-0.2.0.mp4 and /dev/null differ diff --git a/docs/screenshots/enclosure-demo-0.2.1.gif b/docs/screenshots/enclosure-demo-0.2.1.gif new file mode 100644 index 00000000..dcab7518 Binary files /dev/null and b/docs/screenshots/enclosure-demo-0.2.1.gif differ diff --git a/docs/screenshots/enclosure-demo-0.2.1.mp4 b/docs/screenshots/enclosure-demo-0.2.1.mp4 new file mode 100644 index 00000000..5187f905 Binary files /dev/null and b/docs/screenshots/enclosure-demo-0.2.1.mp4 differ diff --git a/examples/demo/enclosure_ui_mcp.py b/examples/demo/enclosure_ui_mcp.py index 90d198ea..05aea369 100644 --- a/examples/demo/enclosure_ui_mcp.py +++ b/examples/demo/enclosure_ui_mcp.py @@ -11,8 +11,8 @@ Rectangular Pattern dialog, then width and wall changed in the Parameters dialog 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. The port and boss sketches use -parametric datum planes (YZ + width/2 and XY + wall), so both follow the edited enclosure. +clicks, and values are typed into the dialog, as by hand. The port and boss sketches attach +directly to the enclosure's side face and inner floor, as in the original demo. 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). @@ -138,6 +138,15 @@ class Demo: self.wait(settle) self.settle_camera() + if v == "home": + # Original Home also fitted the model. The current Axis View only + # rotates: retaining the close sketch camera crops the first extrusion. + self.ui("ui.view", {"view": "fit"}) + self.settle_camera() + vp = self.ui("ui.inspect")["viewport"] + self.ui("ui.scroll", {"x": vp[0] + vp[2] / 2, "y": vp[1] + vp[3] / 2, "delta": -250}) + self.settle_camera() + 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] @@ -231,6 +240,11 @@ def build_box(d, params): d.view("home", 0.6) d.caption("Extrude: height", "UI click") + # The first Fit framed a flat sketch at z=0. Leave room for the upcoming + # 30 mm height instead of letting its far corner grow above the viewport. + vp = d.ui("ui.inspect")["viewport"] + d.ui("ui.scroll", {"x": vp[0] + vp[2] / 2, "y": vp[1] + vp[3] / 2, "delta": -250}) + d.settle_camera() d.tool("solid.extrude") d.wait(0.3) d.type("height") @@ -250,8 +264,10 @@ def build_box(d, params): d.click(d.at(world=[0, 0, 30]), settle=0.3) d.type("wall") - d.caption("USB port datum: YZ + width/2", "UI click · parametric plane") - datum_sketch(d, "YZ", "width/2") + 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.settle_camera() 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) @@ -269,8 +285,10 @@ def build_box(d, params): def run_v2(d): """v2: four standoffs from one sketch, then the width changed over MCP.""" build_box(d, [("width", "80 mm"), ("depth", "60 mm"), ("height", "30 mm"), ("wall", "2 mm")]) - d.caption("Four screw bosses datum: XY + wall", "UI click · parametric plane") - datum_sketch(d, "XY", "wall") + 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.settle_camera() 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) @@ -306,8 +324,10 @@ def run_v3(d): dialog, then width and wall changed in the Parameters dialog.""" build_box(d, [("width", "80 mm"), ("depth", "60 mm"), ("height", "30 mm"), ("wall", "2 mm"), ("inset", "10 mm")]) - d.caption("Screw boss datum: XY + wall", "UI click · parametric plane") - datum_sketch(d, "XY", "wall") + d.caption("Sketch one standoff on the inner floor", "UI click") + d.tool("sketch.create") + d.click(d.at(world=[6, -9, 2]), settle=0.9) + d.settle_camera() d.tool("sketch.circle.center") d.click(d.at(world=[-30, 20, 2]), dur=0.3, settle=0.1) d.click(d.at(world=[-26.5, 20, 2]), dur=0.2, settle=0.15) @@ -347,19 +367,17 @@ def run_v3(d): select_all_and_type(d, d.at(handle="dialog:Spacing 2"), "-(depth - 2*inset)", enter=False) d.wait(0.4) d.click(d.at(handle="dialog:OK"), settle=0.4) - # Up a little, so the corners behind the walls show. + # The original small orbit reveals the corners behind the walls. d.orbit(-60, 45) + # Raise the view enough to see all four 12 mm bosses behind the 30 mm walls. + d.orbit(0, 35) - # Hide the datum planes with their actual browser eye buttons. - for plane in d.datums: - x, y = d.at(handle=f"row:{plane}") - d.click((x - 66.5, y), settle=0.2) d.key("Escape") d.caption("Parameters dialog: width 80 → 100 mm", "UI click") d.tool("parameters.change", panel="panel:SOLID:MODIFY") d.wait(0.3) - # Move the dialog aside and the model over, so both show. + # Original framing: move the dialog aside and pan the model beside it. title = d.at(handle="params:title") d.drag(title, (title[0] - 330, title[1] + 20), steps=25) vp = d.ui("ui.inspect").get("viewport") or [250, 126, 1350, 800] @@ -372,11 +390,8 @@ def run_v3(d): d.click(d.at(handle="params:close"), settle=0.4) d.caption("", "") - d.orbit(300, 0, steps=45) - # End on a high 3/4 view: all four standoffs in sight. + # Keep the original demo's lower three-quarter silhouette. d.view("home", 0.5) - d.orbit(0, 80, steps=40) - d.view("fit", 0.5) d.wait(1.5) @@ -411,6 +426,8 @@ def main(): except StopIteration: pass if a.save and a.until == 10**6: + # Keep this health/save/reopen proof in the raw take, outside public playback. + d.mark(private_start=True) d.settle_camera() design = d.ui("document.inspect", {"measure": True}) if len(design.get("bodies", [])) != 1 or any(f.get("error") or f.get("warning") for f in design.get("timeline", [])): @@ -430,6 +447,29 @@ def main(): raise RuntimeError("the enclosure did not retain all four selected corner fillets") with open(a.save + ".measure.json", "w") as proof: json.dump(design, proof, indent=2) + # Reopen the saved design for the ending: the live pattern preview can leave + # stale display edges behind. This is the actual app, not a rendered substitute. + d.ui("ui.documents", {"action": "close", "force": True}) + d.ui("ui.file", {"path": a.save}) + for _ in range(1200): + if not d.ui("ui.inspect").get("file_busy", False): + break + time.sleep(0.1) + else: + raise RuntimeError("the saved enclosure did not reopen") + d.ui("ui.home", {"open": False}) + d.settle_camera() + d.ui("ui.set", {"showOrigin": False, "showSketches": False}) + d.mark(private_end=True) + d.ui("ui.view", {"view": "home"}) + d.settle_camera() + d.orbit(0, 80) + d.ui("ui.view", {"view": "fit"}) + d.settle_camera() + vp = d.ui("ui.inspect")["viewport"] + d.ui("ui.scroll", {"x": vp[0] + vp[2] / 2, "y": vp[1] + vp[3] / 2, "delta": -120}) + d.settle_camera() + d.glide((1500, 730), 0.4) d.mark(caption="", via="") mcp.close() print(f"done in {time.time() - t0:.1f}s", file=sys.stderr) diff --git a/examples/demo/record_ui.sh b/examples/demo/record_ui.sh index 74805495..3f8e13eb 100755 --- a/examples/demo/record_ui.sh +++ b/examples/demo/record_ui.sh @@ -17,11 +17,26 @@ PAUSE=${SOLVECRAFT_DEMO_PAUSE:-0.55} CAPTION=${SOLVECRAFT_DEMO_CAPTION:-1.4} HOLD=${SOLVECRAFT_DEMO_HOLD:-1} python3 -c 'import math,sys; r=float(sys.argv[1]); assert math.isfinite(r) and 0=1910 and vp[1]+vp[3]>=1000: break + time.sleep(.1) +else: raise RuntimeError("the capture window did not resize") +PY # 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=$! -trap 'kill -INT $FF 2>/dev/null || true' EXIT +# Finalize the recording even when the modelling script refuses an unhealthy take. +# Waiting lets ffmpeg write the MP4 index so the failed take remains inspectable. +trap 'kill -INT $FF 2>/dev/null || true; wait $FF 2>/dev/null || true' EXIT sleep 1 python3 "$HERE/enclosure_ui_mcp.py" --port "$PORT" --cli "$CLI" --story "$STORY" --speed "$PAUSE" --minimum-caption "$CAPTION" --log "$OUT/steps.log" --save "$OUT/enclosure-$STORY.solvecraft" sleep "$HOLD" @@ -32,8 +47,34 @@ read CX CY < "$OUT/cursor.start" ffmpeg -loglevel error -y -i "$OUT/raw.mp4" -loop 1 -i "$OUT/cursor.png" -loop 1 -i "$OUT/ripple.png" -filter_complex \ "[0:v]ass=$OUT/captions.ass[v0];[v0][2:v]overlay@rip=x=-100:y=-100:enable='$(cat "$OUT/ripple.enable")':shortest=1[v1];[v1][1:v]overlay@cur=x=$CX:y=$CY:shortest=1,sendcmd=f=$OUT/cursor.cmd[v]" \ -map "[v]" -c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p -movflags +faststart "$OUT/real-time.mp4" -# Accelerate the completed composite: cursor, ripples and captions stay aligned with the UI. -ffmpeg -loglevel error -y -i "$OUT/real-time.mp4" -vf "setpts=PTS/$RATE" -an \ +# Composite first, then omit the marked private proof interval. The burned-in cursor, +# ripples and captions remain attached to their actual frames when playback time closes. +python3 - "$OUT/steps.log" "$START" "$RATE" "$OUT/public.filter" <<'PY' +import json,math,pathlib,sys +log,start,rate,destination=sys.argv[1:] +start,rate=float(start),float(rate) +p=pathlib.Path(log) +if p.stat().st_size>128*1024*1024: raise RuntimeError("pointer log exceeds capture budget") +markers=[] +with p.open() as stream: + for i,line in enumerate(stream): + if i>=500000: raise RuntimeError("pointer log exceeds event budget") + event=json.loads(line) + for kind in ("private_start","private_end"): + if event.get(kind): markers.append((kind,float(event["t"])-start)) +if markers: + if len(markers)!=2 or [k for k,_ in markers]!=["private_start","private_end"]: + raise RuntimeError("expected one ordered private capture interval") + a,b=(t for _,t in markers) + if not all(math.isfinite(t) for t in (a,b)) or not 0<=a