MCP demo v3 (enclosure_ui_mcp.py --story v3, default): one standoff placed from the parameters (width/2 - inset, depth/2 - inset) and patterned 2 x 2 in the Rectangular Pattern dialog with spacings width - 2*inset and depth - 2*inset, then width 80 -> 100 and wall 2 -> 3 typed into the Parameters dialog; record_v2.sh is now record_ui.sh STORY. A pattern's Direction can be a straight model edge (an edge along an origin axis points the axis's way), scenario pattern_direction_edge. Dialog rows publish their widgets as dialog:<label> handles (combo items dialog:<label>:<item>, OK), the Parameters dialog its expressions (param:<name>), title and close button
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
3b9a975299
commit
8601611d0c
7 files changed
+277
-33
No files matched your search
@@ -363,7 +363,11 @@ impl Dialog {
|
||||
}
|
||||
"solid.pattern.rectangular" => Dialog::new(
|
||||
Kind::PatternRect { count: "3".into(), spacing: "20 mm".into(), count2: "1".into(), spacing2: "20 mm".into() },
|
||||
vec![SelInput::new("Objects", BODIES, true), SelInput::new("Direction", AXES, false), SelInput::new("Direction 2", AXES, false)],
|
||||
vec![
|
||||
SelInput::new("Objects", BODIES, true),
|
||||
SelInput::new("Direction", AXES | EDGES, false),
|
||||
SelInput::new("Direction 2", AXES | EDGES, false),
|
||||
],
|
||||
),
|
||||
"solid.pattern.circular" => Dialog::new(
|
||||
Kind::PatternCirc { count: "6".into(), angle: "360 deg".into() },
|
||||
@@ -713,16 +717,30 @@ fn plane_value(s: &Session, sel: Option<&Sel>) -> Option<Value> {
|
||||
/// A value field; true when Enter was pressed in it.
|
||||
pub(crate) fn field(ui: &mut egui::Ui, s: &mut String) -> bool {
|
||||
let r = ui.text_edit_singleline(s);
|
||||
publish_row(&r);
|
||||
crate::params_dialog::complete(ui, &r, s);
|
||||
r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))
|
||||
}
|
||||
|
||||
pub(crate) fn combo(ui: &mut egui::Ui, id: &str, labels: &[&str], sel: &mut usize) {
|
||||
egui::ComboBox::from_id_salt(id).selected_text(labels.get(*sel).copied().unwrap_or("")).width(FIELD_W).show_ui(ui, |ui| {
|
||||
let row = ROW.with(|r| r.borrow().clone());
|
||||
let r = egui::ComboBox::from_id_salt(id).selected_text(labels.get(*sel).copied().unwrap_or("")).width(FIELD_W).show_ui(ui, |ui| {
|
||||
for (i, l) in labels.iter().enumerate() {
|
||||
ui.selectable_value(sel, i, *l);
|
||||
let item = ui.selectable_value(sel, i, *l);
|
||||
crate::scenario::publish_handle(&format!("dialog:{row}:{l}"), item.rect.center());
|
||||
}
|
||||
});
|
||||
publish_row(&r.response);
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The label of the dialog row being laid out (its widgets publish `dialog:<label>` handles).
|
||||
static ROW: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
|
||||
}
|
||||
|
||||
/// A dialog row's widget, for scenarios and demos: the handle `dialog:<row label>`.
|
||||
fn publish_row(r: &egui::Response) {
|
||||
ROW.with(|row| crate::scenario::publish_handle(&format!("dialog:{}", row.borrow()), r.rect.center()));
|
||||
}
|
||||
|
||||
fn title(k: &Kind) -> &'static str {
|
||||
@@ -805,6 +823,7 @@ const LABEL_W: f32 = 92.0;
|
||||
|
||||
/// A row label in the dialog's label column.
|
||||
pub(crate) fn row_label(ui: &mut egui::Ui, text: &str) {
|
||||
ROW.with(|r| *r.borrow_mut() = text.to_string());
|
||||
ui.allocate_ui_with_layout(vec2(LABEL_W, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_width(LABEL_W);
|
||||
ui.add(egui::Label::new(text).truncate()).on_hover_text(text);
|
||||
@@ -926,7 +945,11 @@ fn placement_row(d: &mut Dialog, ui: &mut egui::Ui) {
|
||||
let (kind, mut inputs) = match ty {
|
||||
0 => (
|
||||
Kind::PatternRect { count: "3".into(), spacing: "20 mm".into(), count2: "1".into(), spacing2: "20 mm".into() },
|
||||
vec![SelInput::new("Objects", BODIES, true), SelInput::new("Direction", AXES, false), SelInput::new("Direction 2", AXES, false)],
|
||||
vec![
|
||||
SelInput::new("Objects", BODIES, true),
|
||||
SelInput::new("Direction", AXES | EDGES, false),
|
||||
SelInput::new("Direction 2", AXES | EDGES, false),
|
||||
],
|
||||
),
|
||||
1 => (
|
||||
Kind::PatternCirc { count: "6".into(), angle: "360 deg".into() },
|
||||
@@ -988,7 +1011,9 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
|
||||
.stroke(stroke)
|
||||
.min_size(vec2(0.0, 22.0))
|
||||
.sense(egui::Sense::CLICK);
|
||||
if ui.add(b).on_hover_text(format!("{} (click to pick into this input)", hint(inp))).clicked() {
|
||||
let chip = ui.add(b);
|
||||
publish_row(&chip);
|
||||
if chip.on_hover_text(format!("{} (click to pick into this input)", hint(inp))).clicked() {
|
||||
activate = Some(i);
|
||||
}
|
||||
if !inp.items.is_empty() && ui.small_button("×").on_hover_text("Clear the selection").clicked() {
|
||||
@@ -1571,7 +1596,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let b = egui::Button::new(RichText::new(label).color(if valid { Color32::WHITE } else { t.text_dim }))
|
||||
.fill(if valid { t.accent } else { Color32::TRANSPARENT })
|
||||
.min_size(vec2(64.0, 24.0));
|
||||
if ui.add_enabled(valid, b).clicked() {
|
||||
let r = ui.add_enabled(valid, b);
|
||||
crate::scenario::publish_handle(&format!("dialog:{label}"), r.rect.center());
|
||||
if r.clicked() {
|
||||
ok = true;
|
||||
}
|
||||
}
|
||||
@@ -1898,9 +1925,9 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
need(0, "objects")?;
|
||||
let kept = d.extra.get("dir1").and_then(|v| serde_json::from_value::<[f64; 3]>(v.clone()).ok()).map(|a| Vec3::new(a[0], a[1], a[2]));
|
||||
let d1 = match (sels(d, 1).first(), kept) {
|
||||
(Some(x), _) => axis_of(app, x).ok_or("the direction must be an axis or a sketch line")?.1,
|
||||
(Some(x), _) => axis_of(app, x).ok_or("the direction must be an axis, a sketch line or a straight edge")?.1,
|
||||
(None, Some(k)) => k,
|
||||
(None, None) => return Err("select a direction (an axis or a sketch line) first".into()),
|
||||
(None, None) => return Err("select a direction (an axis, a sketch line or a straight edge) first".into()),
|
||||
};
|
||||
let mut p = json!({"features": pattern_features(s, sels(d, 0)), "dir1": pt(d1), "count1": count, "spacing1": spacing});
|
||||
if let Some((_, d2)) = sels(d, 2).first().and_then(|x| axis_of(app, x)) {
|
||||
@@ -2276,6 +2303,21 @@ pub fn axis_of(app: &SolveApp, sel: &Sel) -> Option<(Vec3, Vec3)> {
|
||||
let (pa, pb) = (ss.plane.to_world(ss.sketch.point(a)?), ss.plane.to_world(ss.sketch.point(b)?));
|
||||
Some((pa, (pb - pa).normalized()?))
|
||||
}
|
||||
// A straight model edge, along it.
|
||||
Sel::Edge { body, index, .. } => {
|
||||
let b = app.session.world_state().body(body).cloned()?;
|
||||
let m = b.mesh();
|
||||
let e = m.edges.get(*index)?;
|
||||
let (a, z) = (*e.first()?, *e.last()?);
|
||||
let dir = (z - a).normalized()?;
|
||||
let tol = 1e-6 * a.dist(z).max(1.0);
|
||||
if !e.iter().all(|p| (*p - a).cross(dir).len() < tol) {
|
||||
return None;
|
||||
}
|
||||
// An edge along an origin axis points the way the axis does (as picking the axis).
|
||||
let axis = [Vec3::X, Vec3::Y, Vec3::Z].into_iter().find(|x| dir.dot(*x).abs() > 1.0 - 1e-9);
|
||||
Some((a, axis.unwrap_or(dir)))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +188,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
});
|
||||
});
|
||||
// The title bar's close button, for scenarios and demos.
|
||||
if let Some(r) = ctx.memory(|m| m.area_rect(egui::Id::new("sc_params_dialog"))) {
|
||||
crate::scenario::publish_handle("params:close", egui::pos2(r.right() - 16.0, r.top() + 15.0));
|
||||
crate::scenario::publish_handle("params:title", egui::pos2(r.center().x, r.top() + 15.0));
|
||||
}
|
||||
if !open {
|
||||
app.params = None;
|
||||
return;
|
||||
@@ -271,6 +276,7 @@ fn table(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[&R
|
||||
te = te.text_color(t.error);
|
||||
}
|
||||
let resp = ui.add_sized([170.0, 20.0], te);
|
||||
crate::scenario::publish_handle(&format!("param:{}", r.name), resp.rect.center());
|
||||
let completed = complete(ui, &resp, &mut e);
|
||||
if resp.changed() || completed {
|
||||
d.editing.insert(r.name.clone(), e.clone());
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
[
|
||||
{
|
||||
"note": "A Rectangular Pattern's direction can be a straight model edge (as in Fusion), not only an origin axis or a sketch line."
|
||||
},
|
||||
{
|
||||
"call": "engine.execute",
|
||||
"params": {
|
||||
"command": "solid.box",
|
||||
"params": {
|
||||
"length": 20,
|
||||
"width": 10,
|
||||
"height": 10
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"call": "ui.view",
|
||||
"params": {
|
||||
"view": "home"
|
||||
}
|
||||
},
|
||||
{
|
||||
"start": "solid.pattern.rectangular"
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"world": [
|
||||
10,
|
||||
5,
|
||||
10
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "dialog:Direction"
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"world": [
|
||||
10,
|
||||
0,
|
||||
10
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"click": {
|
||||
"handle": "dialog:OK"
|
||||
}
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"dialog": null,
|
||||
"bodies": 3
|
||||
}
|
||||
},
|
||||
{
|
||||
"note": "Along the picked edge (an edge along X points +X, as the axis would): copies 20 mm apart."
|
||||
},
|
||||
{
|
||||
"expect": {
|
||||
"bbox": {
|
||||
"body": "Body3",
|
||||
"min": [
|
||||
40,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"max": [
|
||||
60,
|
||||
10,
|
||||
10
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
+3
-1
@@ -85,7 +85,9 @@ with each step's caption burnt in.
|
||||
modelling goes through the app's UI over its control channel. The pointer glides to toolbar
|
||||
buttons and faces and clicks them, the real dialogs open, the sketches go on the model's faces,
|
||||
and the camera follows. The parameters, the sketch dimensions bound to them and two extrudes go
|
||||
over MCP. `record_v2.sh` records it and draws the pointer and the captions.
|
||||
over MCP. With `--story v3` (the default) one standoff is patterned 2 × 2 in the Rectangular
|
||||
Pattern dialog with spacings from the parameters, and width and wall are then changed in the
|
||||
Parameters dialog. `record_ui.sh` records it and draws the pointer and the captions.
|
||||
|
||||
## A typical session
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
#!/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.
|
||||
"""Demo v2/v3: build an electronics enclosure in a running SolveCraft the way a person would,
|
||||
with the parametric steps sent over MCP.
|
||||
|
||||
--story v3 (default): one standoff placed from the parameters and patterned 2 x 2 in the
|
||||
Rectangular Pattern dialog, then width and wall changed in the Parameters dialog.
|
||||
--story v2: four standoffs from one sketch, then the width changed over MCP.
|
||||
|
||||
solvecraft --control 7878 &
|
||||
python3 examples/demo/enclosure_ui_mcp.py --port 7878 --cli solvecraft-cli
|
||||
python3 examples/demo/enclosure_ui_mcp.py --port 7878 --cli solvecraft-cli [--story v2|v3]
|
||||
|
||||
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
|
||||
@@ -28,6 +32,10 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from enclosure_mcp import Mcp, control # noqa: E402
|
||||
|
||||
|
||||
# Seconds a caption stays up at least.
|
||||
MIN_CAPTION = 1.4
|
||||
|
||||
|
||||
class Demo:
|
||||
def __init__(self, port, mcp, log, speed):
|
||||
self.port, self.mcp, self.log, self.speed = port, mcp, log, speed
|
||||
@@ -44,6 +52,11 @@ class Demo:
|
||||
self.n = getattr(self, "n", 0) + 1
|
||||
if self.n > self.stop:
|
||||
raise StopIteration
|
||||
# Every caption stays up long enough to read.
|
||||
shown = time.time() - getattr(self, "shown_at", 0.0)
|
||||
if shown < MIN_CAPTION:
|
||||
time.sleep(MIN_CAPTION - shown)
|
||||
self.shown_at = time.time()
|
||||
print(f" {text} [{via}]", file=sys.stderr)
|
||||
self.mark(caption=text, via=via)
|
||||
|
||||
@@ -61,7 +74,7 @@ class Demo:
|
||||
p = self.ui("ui.at", where)
|
||||
return (p[0], p[1])
|
||||
|
||||
def glide(self, to, dur=0.35):
|
||||
def glide(self, to, dur=0.3):
|
||||
"""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)
|
||||
@@ -75,7 +88,7 @@ class Demo:
|
||||
time.sleep(dur / n)
|
||||
self.pos = to
|
||||
|
||||
def click(self, to, dur=0.35, settle=0.25):
|
||||
def click(self, to, dur=0.3, settle=0.2):
|
||||
self.glide(to, dur)
|
||||
self.ui("ui.click", {"x": to[0], "y": to[1]})
|
||||
self.mark(cursor=list(to), click=True)
|
||||
@@ -101,6 +114,7 @@ class Demo:
|
||||
def view(self, v, settle=0.7):
|
||||
self.ui("ui.view", {"view": v, "animate": True})
|
||||
self.wait(settle)
|
||||
self.settle_camera()
|
||||
|
||||
def orbit(self, dx, dy=0, steps=40):
|
||||
"""A right-button drag in the viewport, as a user orbits."""
|
||||
@@ -114,6 +128,16 @@ class Demo:
|
||||
self.mark(drag_end=[cx + dx, cy + dy])
|
||||
self.pos = (cx + dx, cy + dy)
|
||||
|
||||
def drag(self, frm, to, button="left", steps=30):
|
||||
"""Press at `frm`, move to `to` and let go (a window's title bar, or a middle-button pan)."""
|
||||
self.glide(frm, 0.25)
|
||||
self.mark(drag_start=list(frm))
|
||||
self.ui("ui.drag", {"x0": frm[0], "y0": frm[1], "x1": to[0], "y1": to[1], "button": button, "steps": steps})
|
||||
self.settle_camera()
|
||||
self.wait(0.2)
|
||||
self.mark(drag_end=list(to))
|
||||
self.pos = tuple(to)
|
||||
|
||||
def settle_camera(self):
|
||||
"""Wait until the app has played queued pointer events (the camera stops moving)."""
|
||||
last, still = None, 0
|
||||
@@ -129,9 +153,9 @@ class Demo:
|
||||
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")]:
|
||||
def build_box(d, params):
|
||||
d.caption("Parameters over MCP: " + ", ".join(f"{n} {e.split()[0]}" for n, e in params), "MCP tools/call execute parameters.change")
|
||||
for name, expr in params:
|
||||
d.execute("parameters.change", {"name": name, "expression": expr})
|
||||
d.wait(0.25)
|
||||
d.wait(0.6)
|
||||
@@ -139,6 +163,7 @@ def run(d):
|
||||
d.caption("Create Sketch on the XY plane", "UI click")
|
||||
d.tool("sketch.create")
|
||||
d.click(d.at(plane="XY"), settle=0.8)
|
||||
d.settle_camera()
|
||||
|
||||
d.caption("Center rectangle from the origin", "UI click")
|
||||
d.tool("sketch.rectangle.center")
|
||||
@@ -185,6 +210,7 @@ def run(d):
|
||||
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)
|
||||
@@ -199,9 +225,13 @@ def run(d):
|
||||
d.wait(0.6)
|
||||
d.orbit(-140, 30)
|
||||
|
||||
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("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)
|
||||
@@ -224,13 +254,96 @@ def run(d):
|
||||
d.view("home", 1.5)
|
||||
|
||||
|
||||
def select_all_and_type(d, at, text, enter=True):
|
||||
"""Click a value field, select what is there (Ctrl+A) and type over it."""
|
||||
d.click(at, settle=0.15)
|
||||
d.ui("ui.key", {"key": "A", "cmd": True})
|
||||
d.wait(0.1)
|
||||
d.type(text, enter)
|
||||
|
||||
|
||||
def run_v3(d):
|
||||
"""v3: one standoff placed from the parameters and patterned 2 x 2 in the Rectangular Pattern
|
||||
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("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)
|
||||
d.key("Escape")
|
||||
|
||||
d.caption("Place it from the parameters: width/2 − inset, depth/2 − inset", "MCP tools/call execute sketch.dimension")
|
||||
sk = json.loads(d.mcp.tool("execute", {"command": "sketch.inspect", "params": {}}))
|
||||
sk = sk.get("result", sk)
|
||||
circles = [c["id"] for c in sk["curves"] if c["type"] == "circle" and not c.get("fixed")]
|
||||
if not circles:
|
||||
raise RuntimeError(f"no circle in the standoff sketch: {[c['id'] for c in sk['curves']]}")
|
||||
circle = circles[0]
|
||||
d.execute("sketch.dimension", {"entities": ["origin", f"{circle}.center"], "type": "horizontal", "value": "width / 2 - inset"})
|
||||
d.execute("sketch.dimension", {"entities": ["origin", f"{circle}.center"], "type": "vertical", "value": "depth / 2 - inset"})
|
||||
d.execute("sketch.dimension", {"entities": [circle], "type": "diameter", "value": "7 mm"})
|
||||
d.wait(0.8)
|
||||
d.tool("sketch.finish")
|
||||
d.view("home", 0.5)
|
||||
|
||||
d.caption("Extrude the standoff: 12 mm", "MCP tools/call execute solid.extrude")
|
||||
d.execute("solid.extrude", {"profiles": [[circle]], "distance": 12, "operation": "join"})
|
||||
d.wait(0.7)
|
||||
standoff = d.ui("ui.handles", {"prefix": "timeline:"})[-1]["name"]
|
||||
|
||||
d.caption("Rectangular Pattern: 2 × 2, spaced width − 2·inset by depth − 2·inset", "UI click")
|
||||
d.tool("solid.pattern.rectangular", panel="panel:SOLID:CREATE")
|
||||
d.click(d.at(handle="dialog:Object Type"), settle=0.25)
|
||||
d.click(d.at(handle="dialog:Object Type:Features"), settle=0.25)
|
||||
d.click(d.at(handle=standoff), settle=0.25)
|
||||
d.click(d.at(handle="dialog:Direction"), settle=0.15)
|
||||
d.click(d.at(world=[0, 30, 30]), settle=0.2)
|
||||
d.click(d.at(handle="dialog:Direction 2"), settle=0.15)
|
||||
d.click(d.at(world=[-40, 0, 30]), settle=0.2)
|
||||
select_all_and_type(d, d.at(handle="dialog:Quantity"), "2", enter=False)
|
||||
select_all_and_type(d, d.at(handle="dialog:Spacing"), "width - 2*inset", enter=False)
|
||||
select_all_and_type(d, d.at(handle="dialog:Quantity 2"), "2", enter=False)
|
||||
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.
|
||||
d.orbit(-60, 45)
|
||||
|
||||
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.
|
||||
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]
|
||||
d.drag((vp[0] + vp[2] * 0.62, vp[1] + vp[3] * 0.5), (vp[0] + vp[2] * 0.62 + 280, vp[1] + vp[3] * 0.5), button="middle", steps=25)
|
||||
select_all_and_type(d, d.at(handle="param:width"), "100 mm")
|
||||
d.wait(1.0)
|
||||
d.caption("… and wall 2 → 3 mm", "UI click")
|
||||
select_all_and_type(d, d.at(handle="param:wall"), "3 mm")
|
||||
d.wait(0.8)
|
||||
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.
|
||||
d.view("home", 0.5)
|
||||
d.orbit(0, 45, steps=25)
|
||||
d.wait(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("--story", choices=["v2", "v3"], default="v3", help="v2: standoffs from one sketch, width over MCP; v3: one standoff patterned, Parameters dialog")
|
||||
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)")
|
||||
ap.add_argument("--speed", type=float, default=0.55, 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"}})
|
||||
@@ -239,7 +352,7 @@ def main():
|
||||
d.stop = a.until - 1
|
||||
t0 = time.time()
|
||||
try:
|
||||
run(d)
|
||||
run_v3(d) if a.story == "v3" else run_v2(d)
|
||||
except StopIteration:
|
||||
pass
|
||||
d.mark(caption="", via="")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"""overlay.py LOG START OUTDIR: from enclosure_ui_mcp.py's --log (captions and pointer events)
|
||||
and the recording's start (epoch seconds), write OUTDIR/captions.ass, OUTDIR/cursor.cmd
|
||||
(ffmpeg sendcmd moves for the drawn pointer and the click ripple) and the two sprites
|
||||
(cursor.png, ripple.png). record_v2.sh composites them onto the screen recording."""
|
||||
(cursor.png, ripple.png). record_ui.sh composites them onto the screen recording."""
|
||||
|
||||
import json
|
||||
import math
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
#!/bin/bash
|
||||
# record_v2.sh DISPLAY PORT OUTDIR: record enclosure_ui_mcp.py driving the app on X display
|
||||
# DISPLAY (1920x1080, started with --control PORT) to OUTDIR/enclosure-demo-v2.mp4 (H.264) and
|
||||
# .gif, with captions and a drawn pointer (the app gets synthetic pointer events, so the
|
||||
# screen's own pointer never moves). Needs ffmpeg with x11grab and libass.
|
||||
# record_ui.sh DISPLAY PORT OUTDIR [STORY]: record enclosure_ui_mcp.py --story STORY (v2 or v3,
|
||||
# default v3) driving the app on X display DISPLAY (1920x1080, started with --control PORT) to
|
||||
# OUTDIR/enclosure-demo-STORY.mp4 (H.264) and .gif, with captions and a drawn pointer (the app
|
||||
# gets synthetic pointer events, so the screen's own pointer never moves). Needs ffmpeg with
|
||||
# x11grab and libass.
|
||||
set -e
|
||||
DISP=${1:-:0}; PORT=${2:-7878}; OUT=$(cd "${3:-.}" && pwd)
|
||||
DISP=${1:-:0}; PORT=${2:-7878}; OUT=$(cd "${3:-.}" && pwd); STORY=${4:-v3}
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
CLI=${SOLVECRAFT_CLI:-solvecraft-cli}
|
||||
# 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 1.5
|
||||
python3 "$HERE/enclosure_ui_mcp.py" --port "$PORT" --cli "$CLI" --log "$OUT/steps.log"
|
||||
sleep 1.5
|
||||
trap 'kill -INT $FF 2>/dev/null || true' EXIT
|
||||
sleep 1
|
||||
python3 "$HERE/enclosure_ui_mcp.py" --port "$PORT" --cli "$CLI" --story "$STORY" --log "$OUT/steps.log"
|
||||
sleep 1
|
||||
kill -INT $FF; wait $FF || true
|
||||
START=$(grep -o "start: [0-9.]*" "$OUT/ffmpeg.log" | head -1 | cut -d" " -f2)
|
||||
python3 "$HERE/overlay.py" "$OUT/steps.log" "$START" "$OUT"
|
||||
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/enclosure-demo-v2.mp4"
|
||||
ffmpeg -loglevel error -y -i "$OUT/enclosure-demo-v2.mp4" \
|
||||
-vf "fps=10,scale=800:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=96:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=4" \
|
||||
"$OUT/enclosure-demo-v2.gif"
|
||||
ls -la "$OUT"/enclosure-demo-v2.*
|
||||
-map "[v]" -c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p -movflags +faststart "$OUT/enclosure-demo-$STORY.mp4"
|
||||
ffmpeg -loglevel error -y -i "$OUT/enclosure-demo-$STORY.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" \
|
||||
"$OUT/enclosure-demo-$STORY.gif"
|
||||
ls -la "$OUT"/enclosure-demo-$STORY.*
|
||||
Reference in new issue
Block a user