docs: restore the original enclosure demo in the updated UI

This commit is contained in:
bherbruck committed 2026-10-11 15:05:16 -04:00
1 parent 9bcd8ffd56
commit 6f872c7789
8 files changed
+106 -25

No files matched your search

+2 -2
View File
@@ -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 |
+2 -2
View File
@@ -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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.
+58 -18
View File
@@ -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)
+44 -3
View File
@@ -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<r<=16' "$RATE"
# Frame the actual app at capture resolution before the first recorded blank viewport.
python3 - "$HERE" "$PORT" <<'PY'
import sys,time
sys.path.insert(0,sys.argv[1])
from enclosure_mcp import control
r=control(int(sys.argv[2]),"ui.resize",{"width":1920,"height":1080})
if not r.get("ok"): raise RuntimeError(r)
for _ in range(100):
vp=control(int(sys.argv[2]),"ui.inspect").get("result",{}).get("viewport",[])
if len(vp)==4 and vp[0]+vp[2]>=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<b<=86400:
raise RuntimeError("invalid private capture interval")
select=f"select='not(between(t,{a:.6f},{b:.6f}))',"
else:
select="" # Partial rehearsals have no health/save/reopen interval.
pathlib.Path(destination).write_text(select+f"setpts=N/(FRAME_RATE*TB*{rate})")
PY
# Accelerate the public composite: cursor, ripples and captions stay aligned with the UI.
ffmpeg -loglevel error -y -i "$OUT/real-time.mp4" -vf "$(cat "$OUT/public.filter")" -an \
-c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p -movflags +faststart "$OUT/$NAME.mp4"
ffmpeg -loglevel error -y -i "$OUT/$NAME.mp4" \
-vf "fps=8,scale=720:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=80:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=4" \