Compare commits

..
5 Commits
Author SHA1 Message Date
bherbruckandClaude Opus 5.5 3966b8b3b9 Version 0.1.1: the save prompt on quit (#33) and the web black-screen fix (#34); release notes for v0.1.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:40:04 +00:00
bherbruckandClaude Opus 5.5 bd8e8c4809 Web: the viewport shader compiles in browsers (a new design no longer shows a black screen)
Fixes #34

The surface-analysis curvature map took screen-space derivatives (dpdx/dpdy) inside a branch
of fs_solid. naga accepts that, but browsers' WGSL compiler (Tint, WebGPU) requires derivatives
in uniform control flow and rejects the whole `sc_viewport` shader module, so every viewport
pipeline was invalid and the viewport stayed black as soon as a design was shown. The
curvature is now worked out first thing in fs_solid and passed in, and the canvas image
shader samples its texture before discarding.

Regression test: every fragment entry point makes derivative and implicit-LOD sampling calls
(directly or through helpers) before any branch or return. Checked in headless Chromium with
WebGPU (SwiftShader): before, "dpdx must only be called from uniform control flow" and invalid
pipelines on New Design; after, no shader or pipeline errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:22:22 +00:00
bherbruckandClaude Opus 5.5 4562c8e5ba Save prompt on quit: "Save changes to <name>?" for every unsaved design
The title bar's ×, File ▸ Quit, the OS closing the window (Alt+F4, Cmd+Q, the window manager:
close_requested is cancelled until the designs are answered) and app.quit / ui.window close all
go through documents::request_quit, which asks Save / Don't Save / Cancel once per design with
unsaved changes. Save saves (Save As for a never-saved design) and quits only if it saved; Don't
Save discards (no recovery copy is kept); Cancel or Esc stays open. Closing a tab asks the same
question (now titled "Save changes to <name>?"); New and Open never replace an unsaved design
(they open a tab). The web app asks through beforeunload while a design is unsaved. Control:
ui.documents {action: quit, answer: save|dont_save|cancel}, app.quit {force}. Tests: quit with
Save then Don't Save across two tabs, Cancel, a Save that saves nothing, Save As; scenario
s43_quit_asks_to_save.

Fixes #33

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:03:32 +00:00
bherbruckandClaude Opus 5.5 d2bc8e9847 README: a 3x-speed demo GIF at the top (agent builds the enclosure over MCP, then edits parameters)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:54:48 +00:00
bherbruckandClaude Opus 5.5 8601611d0c 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 <noreply@anthropic.com>
2026-10-09 15:09:29 +00:00
24 changed files with 649 additions and 93 deletions

No files matched your search

+20 -7
View File
@@ -1,7 +1,20 @@
SolveCraft is an open-source parametric 3D CAD application in pure Rust: sketch on a plane,
constrain and dimension the sketch, turn profiles into solids on a feature timeline, and change a
parameter to watch the timeline rebuild. This is the first public build, a **pre-release**: it is
early, and we'd like your bug reports.
parameter to watch the timeline rebuild. This is a **pre-release**: it is early, and we'd like
your bug reports.
## Fixed since v0.1.0
- **Quitting with unsaved changes now asks first** ([#33](https://github.com/bherbruck/solvecraft/issues/33)):
"Save changes to <name>?" with Save, Don't Save and Cancel, once per unsaved design, for the
title bar's ×, File ▸ Quit, Alt+F4 / Cmd+Q and the window manager. The web app asks through
the browser's "leave site?" prompt.
- **Web: a new design no longer shows a black screen** in browsers with WebGPU
([#34](https://github.com/bherbruck/solvecraft/issues/34)); the viewport shader compiles there now.
- **README demo:** a short GIF at the top shows an agent building an enclosure over MCP and then
editing its parameters. The full video is attached below.
## Links
- **Documentation:** https://bherbruck.github.io/solvecraft/
- **Try it in the browser:** https://bherbruck.github.io/solvecraft/app/ (`?sample` opens a sample part)
@@ -36,11 +49,11 @@ attached video shows an agent building an enclosure through the UI and MCP.
| File | What |
|---|---|
| `solvecraft-v0.1.0-windows-x86_64.zip` | Windows 10/11: `solvecraft.exe` (the app) and `solvecraft-cli.exe` |
| `solvecraft-v0.1.0-linux-x86_64.tar.gz` | Linux x86_64 (glibc 2.35 or newer): `solvecraft` and `solvecraft-cli` |
| `SolveCraft-v0.1.0-x86_64.AppImage` | Linux, the app as one file: `chmod +x` and run it |
| `solvecraft-v0.1.0-macos-universal.zip` | macOS 11 or newer, Apple silicon and Intel: `SolveCraft.app` and `solvecraft-cli` |
| `solvecraft-v0.1.0-web.zip` | the web app, to host yourself (any static server; HTTPS or localhost for saving files) |
| `solvecraft-v0.1.1-windows-x86_64.zip` | Windows 10/11: `solvecraft.exe` (the app) and `solvecraft-cli.exe` |
| `solvecraft-v0.1.1-linux-x86_64.tar.gz` | Linux x86_64 (glibc 2.35 or newer): `solvecraft` and `solvecraft-cli` |
| `SolveCraft-v0.1.1-x86_64.AppImage` | Linux, the app as one file: `chmod +x` and run it |
| `solvecraft-v0.1.1-macos-universal.zip` | macOS 11 or newer, Apple silicon and Intel: `SolveCraft.app` and `solvecraft-cli` |
| `solvecraft-v0.1.1-web.zip` | the web app, to host yourself (any static server; HTTPS or localhost for saving files) |
| `SHA256SUMS` | checksums: `sha256sum -c SHA256SUMS` |
### The binaries are not signed
+1
View File
@@ -18,6 +18,7 @@ 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 | screen recording of SolveCraft itself driven by examples/demo (3x speed) | 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 |
Generated
+13 -13
View File
@@ -3059,7 +3059,7 @@ dependencies = [
[[package]]
name = "solvecraft"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"eframe",
"egui",
@@ -3072,7 +3072,7 @@ dependencies = [
[[package]]
name = "solvecraft-cli"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3081,7 +3081,7 @@ dependencies = [
[[package]]
name = "solvecraft-doc"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"log",
"serde",
@@ -3095,7 +3095,7 @@ dependencies = [
[[package]]
name = "solvecraft-engine"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"log",
"serde",
@@ -3111,7 +3111,7 @@ dependencies = [
[[package]]
name = "solvecraft-geom"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"serde",
"serde_json",
@@ -3119,7 +3119,7 @@ dependencies = [
[[package]]
name = "solvecraft-io"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"quick-xml 0.38.4",
"solvecraft-doc",
@@ -3131,7 +3131,7 @@ dependencies = [
[[package]]
name = "solvecraft-kernel"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"log",
"serde",
@@ -3146,7 +3146,7 @@ dependencies = [
[[package]]
name = "solvecraft-mcp"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"serde_json",
"solvecraft-engine",
@@ -3154,7 +3154,7 @@ dependencies = [
[[package]]
name = "solvecraft-render"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"image",
"serde",
@@ -3163,7 +3163,7 @@ dependencies = [
[[package]]
name = "solvecraft-sketch"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"serde",
"serde_json",
@@ -3174,7 +3174,7 @@ dependencies = [
[[package]]
name = "solvecraft-ui-egui"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"egui",
"egui-wgpu",
@@ -3188,7 +3188,7 @@ dependencies = [
[[package]]
name = "solvecraft-web"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"eframe",
"egui",
@@ -4487,7 +4487,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.1.0"
version = "0.1.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
+3 -1
View File
@@ -6,7 +6,9 @@ with extrude and revolve, round edges, combine bodies, and change a parameter to
timeline rebuild. It is a clean-room implementation (see [CLAUDE.md](CLAUDE.md)) and part of the
storytold "craft" family.
![SolveCraft with the sample plate](docs/screenshots/sample-plate.png)
![An AI agent builds a parametric enclosure in SolveCraft over MCP, then the width is changed in the Parameters dialog and the model rebuilds (3x speed)](docs/screenshots/demo.gif)
*An agent builds an electronics enclosure over MCP: sketches on faces, shell, a port cut, one screw boss patterned to four, then width 80 → 100 mm in the Parameters dialog and the timeline rebuilds. 3× speed; [full video](https://github.com/bherbruck/solvecraft/releases/download/v0.1.0/enclosure-demo-v3.mp4), script in [examples/demo](examples/demo).*
**Documentation:** the [SolveCraft book](https://bherbruck.github.io/solvecraft/) (user guide,
developer guide and command reference; sources in [docs/book](docs/book)), and the
+1
View File
@@ -29,4 +29,5 @@ web-sys = { version = "0.3", features = [
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
"BeforeUnloadEvent", "Event", "EventTarget",
] }
+23 -1
View File
@@ -27,6 +27,25 @@ mod web {
/// Seconds between autosaves of unsaved work.
const AUTOSAVE_S: f64 = 30.0;
thread_local! {
/// An open design has unsaved changes: leaving the page asks first.
static UNSAVED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// The browser's "leave site? changes you made may not be saved" prompt while a design has
/// unsaved changes (the page can't show its own dialog when it is being closed).
fn ask_before_unload(window: &web_sys::Window) {
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(|e: web_sys::BeforeUnloadEvent| {
if UNSAVED.with(std::cell::Cell::get) {
e.prevent_default();
e.set_return_value("unsaved");
}
});
let _ = window.add_event_listener_with_callback("beforeunload", on_unload.as_ref().unchecked_ref());
// The listener lives as long as the page.
on_unload.forget();
}
struct Shell {
app: SolveApp,
files: Rc<Files>,
@@ -291,6 +310,7 @@ mod web {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.app.logic(ctx);
self.files_tick(ctx);
UNSAVED.with(|u| u.set(solvecraft_ui_egui::documents::any_dirty(&self.app)));
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
files::take_drops(&self.files, raw);
@@ -317,7 +337,9 @@ mod web {
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
wasm_bindgen_futures::spawn_local(async {
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
let Some(window) = web_sys::window() else { return };
ask_before_unload(&window);
let Some(document) = window.document() else { return };
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
+6
View File
@@ -21,6 +21,12 @@ struct App(SolveApp);
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// The OS closing the window (Alt+F4, Cmd+Q, the window manager): ask about unsaved
// designs first; the window closes once they are answered (`quit_requested`).
if ctx.input(|i| i.viewport().close_requested()) && !self.0.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
solvecraft_ui_egui::documents::request_quit(&mut self.0);
}
self.0.logic(ctx);
if self.0.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
+12 -3
View File
@@ -111,6 +111,8 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
"preview": {"active": app.preview.active, "busy": app.preview.busy, "error": app.preview.error, "ms": app.preview.ms, "replaced": app.preview.replaced},
"home": app.home.open,
"documents": app.docs.count(),
"quit_requested": app.quit_requested,
"quitting": app.docs.quitting.is_some(),
})),
"ui.documents" => ok(crate::documents::control(app, p)),
"ui.home" => {
@@ -459,9 +461,16 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
}
}
"app.quit" => {
app.quit_requested = true;
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
ok(Value::Null)
// `force`: quit without asking about unsaved designs.
if p.get("force").and_then(Value::as_bool).unwrap_or(false) {
app.quit_requested = true;
} else {
crate::documents::request_quit(app);
}
if app.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
ok(json!({"quitting": app.quit_requested, "asking": app.docs.closing.is_some()}))
}
other => err(format!("unknown method `{other}`")),
}
+50 -8
View File
@@ -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,
}
}
+168 -20
View File
@@ -1,7 +1,8 @@
//! Several open designs, as tabs in the application bar. The active design lives in the app as
//! always (`app.session`, `app.cam`…); the others wait here with their own session (undo,
//! selection), camera and per-design view state, and are swapped in when their tab is picked.
//! Closing a design with unsaved changes asks first.
//! Closing a design with unsaved changes asks first, and so does quitting: one "save changes?"
//! per design with unsaved changes ([`request_quit`]).
use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2};
use serde_json::{Value, json};
@@ -48,6 +49,8 @@ pub struct Documents {
pub active: usize,
/// A tab waiting for the answer to "save changes?".
pub closing: Option<usize>,
/// Quitting: the tabs answered so far with Don't Save (`None`: not quitting).
pub quitting: Option<Vec<usize>>,
}
impl Documents {
@@ -214,6 +217,44 @@ pub fn close(app: &mut SolveApp, i: usize, force: bool) {
}
}
/// Quit (the title bar's ×, File ▸ Quit, the window being closed): asks "save changes?" for each
/// design with unsaved changes, then sets `app.quit_requested` for the host to close the window.
/// Cancel, or a Save that didn't save, stays open.
pub fn request_quit(app: &mut SolveApp) {
if app.docs.quitting.is_none() {
app.docs.quitting = Some(Vec::new());
}
quit_step(app);
}
/// Ask about the next design with unsaved changes, or quit when none is left.
fn quit_step(app: &mut SolveApp) {
let Some(skip) = app.docs.quitting.clone() else { return };
if app.docs.closing.is_some() {
return;
}
match (0..app.docs.count()).find(|i| !skip.contains(i) && dirty(app, *i)) {
Some(i) => {
switch(app, i);
app.docs.closing = Some(i);
}
None => {
// Don't Save means discard: no recovery copy is kept for the next launch either.
for i in skip {
switch(app, i);
app.session.mark_saved();
}
app.docs.quitting = None;
app.quit_requested = true;
}
}
}
/// Is any open design unsaved (the web page asks before it is left)?
pub fn any_dirty(app: &SolveApp) -> bool {
(0..app.docs.count()).any(|i| dirty(app, i))
}
fn dirty(app: &SolveApp, i: usize) -> bool {
if i == app.docs.active || app.docs.slots.is_empty() {
return app.session.is_dirty();
@@ -236,18 +277,28 @@ pub fn tabs_info(app: &SolveApp) -> Vec<(String, bool)> {
.collect()
}
/// The control channel's `ui.documents`: list, `new`, `switch` or `close` (by index; `force`
/// closes without asking).
/// The control channel's `ui.documents`: list, `new`, `switch`, `close` (by index; `force`
/// closes without asking) or `quit`; `answer`: save | dont_save | cancel answers "save changes?".
pub fn control(app: &mut SolveApp, p: &Value) -> Value {
let i = p.get("index").and_then(Value::as_u64).map(|x| x as usize);
match p.get("action").and_then(Value::as_str) {
Some("new") => new_design(app),
Some("switch") => switch(app, i.unwrap_or(app.docs.active)),
Some("close") => close(app, i.unwrap_or(app.docs.active), p.get("force").and_then(Value::as_bool).unwrap_or(false)),
Some("quit") => request_quit(app),
_ => {}
}
let answer = match p.get("answer").and_then(Value::as_str) {
Some("save") => Some(Answer::Save),
Some("dont_save") => Some(Answer::DontSave),
Some("cancel") => Some(Answer::Cancel),
_ => None,
};
if let Some(a) = answer {
answer_prompt(app, a);
}
let tabs: Vec<Value> = tabs_info(app).into_iter().map(|(name, dirty)| json!({"name": name, "dirty": dirty})).collect();
json!({"documents": tabs, "active": app.docs.active, "closing": app.docs.closing})
json!({"documents": tabs, "active": app.docs.active, "closing": app.docs.closing, "quitting": app.docs.quitting.is_some()})
}
/// The document tabs in the application bar, from `x0` to `x1`: click to switch, × to close,
@@ -375,50 +426,81 @@ pub fn layout(n: usize, active: usize, room: f32) -> (usize, usize, f32) {
(first, fit, MIN_TAB)
}
/// The "save changes?" prompt for a tab being closed.
/// What "save changes?" was answered.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Answer {
Save,
DontSave,
Cancel,
}
/// The "save changes?" prompt for a tab being closed, or for each unsaved design when quitting.
pub fn prompt(app: &mut SolveApp, ctx: &egui::Context) {
quit_step(app);
let Some(i) = app.docs.closing else { return };
let name = tabs_info(app).get(i).map(|x| x.0.clone()).unwrap_or_default();
let mut answer: Option<u8> = None;
crate::frame::window(ctx, "Save changes?", crate::frame::Width::Normal)
let mut answer: Option<Answer> = None;
crate::frame::window(ctx, format!("Save changes to {name}?"), crate::frame::Width::Normal)
.id(egui::Id::new("sc_close_prompt"))
.collapsible(false)
.resizable(false)
.anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
.show(ctx, |ui| {
ui.label(format!("{name} has unsaved changes."));
ui.label(format!("{name} has unsaved changes. If you don't save, they are lost."));
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button("Save").clicked() {
answer = Some(0);
answer = Some(Answer::Save);
}
if ui.button("Don't Save").clicked() {
answer = Some(1);
answer = Some(Answer::DontSave);
}
if ui.button("Cancel").clicked() {
answer = Some(2);
answer = Some(Answer::Cancel);
}
});
});
if ctx.input(|x| x.key_pressed(egui::Key::Escape)) {
answer = Some(2);
answer = Some(Answer::Cancel);
app.esc_handled = true;
}
if let Some(a) = answer {
answer_prompt(app, a);
}
}
/// Act on the "save changes?" answer (also the control channel's `ui.documents {answer}`).
pub fn answer_prompt(app: &mut SolveApp, answer: Answer) {
let Some(i) = app.docs.closing else { return };
match answer {
Some(0) => {
Answer::Save => {
switch(app, i);
crate::toolbar::save(app);
// Saved (not cancelled in the file picker): close it.
if !app.session.is_dirty() {
let saved = !app.session.is_dirty();
app.docs.closing = None;
if app.docs.quitting.is_some() {
// Quit only once it is saved; a cancelled picker or a failed save stays open.
if !saved {
app.docs.quitting = None;
}
} else if saved {
close(app, app.docs.active, true);
} else {
app.docs.closing = None;
}
}
Some(1) => close(app, i, true),
Some(2) => app.docs.closing = None,
_ => {}
Answer::DontSave => {
if let Some(skip) = app.docs.quitting.as_mut() {
skip.push(i);
app.docs.closing = None;
} else {
close(app, i, true);
}
}
Answer::Cancel => {
app.docs.closing = None;
app.docs.quitting = None;
}
}
quit_step(app);
}
#[cfg(test)]
@@ -473,6 +555,72 @@ mod tests {
assert!(active_is_blank(&a) && a.home.open);
}
/// Quitting asks once per design with unsaved changes: Don't Save moves on (and keeps no
/// recovery copy), Save saves and moves on, and the app quits after the last answer.
#[test]
fn quit_asks_about_each_unsaved_design_then_quits() {
let mut a = app();
request_quit(&mut a);
assert!(a.quit_requested, "nothing unsaved: quits at once");
let mut a = app();
let file = std::env::temp_dir().join(format!("solvecraft-quit-save-{}.solvecraft", std::process::id()));
let _ = std::fs::remove_file(&file);
a.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
a.run("file.save_as", json!({"path": file.to_string_lossy()})).unwrap();
a.run("solid.sphere", json!({"diameter": 4})).unwrap();
new_design(&mut a);
a.run("solid.sphere", json!({"diameter": 8})).unwrap();
assert!(dirty(&a, 0) && dirty(&a, 1));
request_quit(&mut a);
assert!(!a.quit_requested);
assert_eq!(a.docs.closing, Some(0), "the first unsaved design is asked about first");
assert_eq!(a.docs.active, 0, "and shown");
answer_prompt(&mut a, Answer::Save);
assert!(!dirty(&a, 0), "saved");
assert_eq!(
std::fs::read(&file).ok().and_then(|b| solvecraft_engine::io::read_design(&b).ok()).map(|d| d.features.len()),
Some(2),
"the file holds the change"
);
assert_eq!(a.docs.closing, Some(1), "then the next one");
assert!(!a.quit_requested);
answer_prompt(&mut a, Answer::DontSave);
assert_eq!(a.docs.closing, None);
assert!(a.quit_requested, "every design answered: quit");
assert!(!any_dirty(&a), "Don't Save discards: nothing is kept for recovery");
let _ = std::fs::remove_file(&file);
}
/// Cancel, Esc's answer, or a Save that saves nothing (a never-saved design whose Save As
/// picker is cancelled) stay open with the changes.
#[test]
fn quit_cancelled_or_unsaved_stays_open() {
let mut a = app();
a.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap();
request_quit(&mut a);
assert_eq!(a.docs.closing, Some(0));
answer_prompt(&mut a, Answer::Cancel);
assert!(!a.quit_requested && a.docs.closing.is_none() && a.docs.quitting.is_none());
assert!(a.session.is_dirty(), "the changes are still there");
// No Save As picker answer (the default services have none): not saved, so no quit.
request_quit(&mut a);
answer_prompt(&mut a, Answer::Save);
assert!(!a.quit_requested && a.docs.quitting.is_none() && a.session.is_dirty());
// A picked file: saved, then quit.
let file = std::env::temp_dir().join(format!("solvecraft-quit-saveas-{}.solvecraft", std::process::id()));
let _ = std::fs::remove_file(&file);
let to = file.to_string_lossy().to_string();
a.services.pick_save = Some(Box::new(move |_: &str, _: &[&str]| Some(to.clone())));
request_quit(&mut a);
answer_prompt(&mut a, Answer::Save);
assert!(a.quit_requested && file.exists());
let _ = std::fs::remove_file(&file);
}
/// A design built elsewhere replaces a blank one and gets a tab otherwise.
#[test]
fn adopt_reuses_a_blank_design() {
+94 -8
View File
@@ -318,18 +318,30 @@ fn vs_tri(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c:
/// Opaque model surfaces: cut by the section plane, the inside shows as a flat cap colour.
@fragment
fn fs_solid(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
// Derivatives first, in uniform control flow (browsers' WGSL compilers reject them after
// a branch that returns; the whole shader module then fails and nothing is drawn).
let k = curvature(i);
if (clipped(i.wp)) { discard; }
if (!front && length(u.clip.xyz) > 0.5) {
return out_color(u.cap);
}
if (u.ana.x > 0.5 && length(i.n) > 0.5) {
return analysis(i);
return analysis(i, k);
}
return shade(i);
}
/// Surface analysis colours: zebra stripes, draft angle bands, curvature map.
fn analysis(i: TOut) -> vec4<f32> {
/// How fast the normal turns across the pixel (per mm).
fn curvature(i: TOut) -> f32 {
let nn = normalize(i.n);
let kx = length(dpdx(nn)) / max(length(dpdx(i.wp)), 1e-6);
let ky = length(dpdy(nn)) / max(length(dpdy(i.wp)), 1e-6);
return max(kx, ky);
}
/// Surface analysis colours: zebra stripes, draft angle bands, curvature map (`k`: the
/// surface's curvature here, worked out by the caller).
fn analysis(i: TOut, k: f32) -> vec4<f32> {
let v = normalize(u.back.xyz);
var n = normalize(i.n);
if (dot(n, v) < 0.0) { n = -n; }
@@ -385,10 +397,7 @@ fn analysis(i: TOut) -> vec4<f32> {
return out_color(vec4<f32>(c * lit, 1.0));
}
// Curvature from how fast the normal turns across the pixel.
let nn = normalize(i.n);
let kx = length(dpdx(nn)) / max(length(dpdx(i.wp)), 1e-6);
let ky = length(dpdy(nn)) / max(length(dpdy(i.wp)), 1e-6);
let t = clamp(max(kx, ky) * u.ana.y, 0.0, 1.0);
let t = clamp(k * u.ana.y, 0.0, 1.0);
let c = select(mix(vec3<f32>(0.10, 0.80, 0.25), vec3<f32>(0.90, 0.15, 0.10), t * 2.0 - 1.0),
mix(vec3<f32>(0.15, 0.35, 0.95), vec3<f32>(0.10, 0.80, 0.25), t * 2.0), t < 0.5);
return out_color(vec4<f32>(c * lit, 1.0));
@@ -511,8 +520,8 @@ fn vs_img(@location(0) p: vec3<f32>, @location(1) uv: vec2<f32>, @location(2) a:
@fragment
fn fs_img(i: IOut) -> @location(0) vec4<f32> {
if (clipped(i.wp)) { discard; }
let c = textureSample(img_t, img_s, i.uv);
if (clipped(i.wp)) { discard; }
return out_color(vec4<f32>(c.rgb, c.a * i.a));
}
@@ -954,4 +963,81 @@ mod tests {
let module = naga::front::wgsl::parse_str(super::SHADER).unwrap_or_else(|e| panic!("{}", e.emit_to_string(super::SHADER)));
naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::empty()).validate(&module).unwrap();
}
/// Derivatives and implicit-lod sampling run only in uniform control flow: browsers' WGSL
/// compilers (Tint) reject them after a branch, and the whole viewport shader then fails,
/// a black screen on the web (naga accepts it). Every fragment entry point makes such calls
/// (directly or through a helper) before its first branch or return.
#[test]
fn derivatives_only_in_uniform_control_flow() {
// Without comments (their words are not code).
let src: String = super::SHADER.lines().map(|l| l.split("//").next().unwrap_or("")).collect::<Vec<_>>().join("\n");
let src = src.as_str();
let derivs = ["dpdx(", "dpdy(", "fwidth(", "textureSample("];
// Functions: name -> body.
let mut fns: Vec<(String, String, bool)> = Vec::new();
let mut rest = src;
let mut fragment = false;
while let Some(at) = rest.find("fn ") {
let before = &rest[..at];
if before.contains("@fragment") {
fragment = true;
}
let Some(open) = rest[at..].find('{') else { break };
let name = rest[at + 3..].split('(').next().unwrap_or("").trim().to_string();
let start = at + open;
let mut depth = 0;
let mut end = start;
for (k, ch) in rest[start..].char_indices() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = start + k;
break;
}
}
_ => {}
}
}
fns.push((name, rest[start..=end].to_string(), fragment));
fragment = false;
rest = &rest[end + 1..];
}
// Helpers that take derivatives (directly or through another helper).
let mut uses: Vec<String> = Vec::new();
for _ in 0..8 {
for (n, body, _) in &fns {
if !uses.contains(n) && (derivs.iter().any(|d| body.contains(d)) || uses.iter().any(|u| body.contains(&format!("{u}(")))) {
uses.push(n.clone());
}
}
}
for (n, body, frag) in &fns {
let calls: Vec<usize> = derivs
.iter()
.map(|d| d.to_string())
.chain(uses.iter().map(|u| format!("{u}(")))
.flat_map(|c| body.match_indices(&c).map(|(i, _)| i).collect::<Vec<_>>())
.collect();
if calls.is_empty() {
continue;
}
if !*frag {
// A helper: its own derivatives must come before any branch or return in it.
let first = ["if (", "return", "discard"].iter().filter_map(|k| body.find(k)).min().unwrap_or(usize::MAX);
for d in derivs {
if let Some(i) = body.find(d) {
assert!(i < first, "{n}: {d} after a branch");
}
}
continue;
}
let first = ["if (", "return", "discard"].iter().filter_map(|k| body.find(k)).min().unwrap_or(usize::MAX);
for c in calls {
assert!(c < first, "fragment {n}: a derivative after a branch or return: {}", &body[c..(c + 40).min(body.len())]);
}
}
}
}
+6
View File
@@ -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());
+5
View File
@@ -738,6 +738,11 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> {
return Err(format!("start page open: got {}, want {v}", ui["home"]));
}
}
"quit_requested" | "quitting" => {
if ui[k] != *v {
return Err(format!("{k}: got {}, want {v}", ui[k]));
}
}
"documents" => {
if ui["documents"] != *v {
return Err(format!("open designs: got {}, want {v}", ui["documents"]));
+5 -2
View File
@@ -57,8 +57,11 @@ pub fn window_action(app: &mut SolveApp, ctx: &egui::Context, action: &str) -> R
"restore" => ctx.send_viewport_cmd(ViewportCommand::Maximized(false)),
"toggle" => toggle_maximize(ctx),
"close" => {
app.quit_requested = true;
ctx.send_viewport_cmd(ViewportCommand::Close);
// Unsaved designs are asked about first; the window closes once they are answered.
crate::documents::request_quit(app);
if app.quit_requested {
ctx.send_viewport_cmd(ViewportCommand::Close);
}
}
other => return Err(format!("unknown window action `{other}` (minimize, maximize, restore, toggle, close)")),
}
+1 -1
View File
@@ -194,7 +194,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
close = true;
}
if item(ui, "Quit", "") {
app.quit_requested = true;
crate::documents::request_quit(app);
close = true;
}
});
@@ -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
]
}
}
}
]
@@ -0,0 +1,16 @@
[
{"note": "Issue #33: quitting with unsaved changes asks \"Save changes to <name>?\"; Cancel stays open."},
{"call": "engine.execute", "params": {"command": "solid.box", "params": {"length": 20, "width": 10, "height": 5}}},
{"call": "ui.window", "params": {"action": "close"}},
{"expect": {"shown": "Don't Save", "quitting": true, "quit_requested": false}},
{"widget": "Cancel"},
{"expect": {"not_shown": "Don't Save", "quitting": false, "quit_requested": false, "bodies": 1}},
{"note": "The File menu's Quit asks the same; Esc answers Cancel."},
{"call": "app.quit"},
{"expect": {"shown": "Don't Save", "quit_requested": false}},
{"key": "Escape"},
{"expect": {"not_shown": "Don't Save", "quitting": false, "quit_requested": false}},
{"call": "app.quit"},
{"widget": "Don't Save"},
{"expect": {"quit_requested": true}}
]
+3 -3
View File
@@ -45,15 +45,15 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints
| `ui.screenshot` | `path?` | PNG of the window (needs a presented frame) |
| `ui.render` | `path`, `width?`, `height?` | CPU render of the model with the current camera (no window needed) |
| `ui.confirm` | `accept?`: bool | answers the Delete confirmation (shown when a delete takes other features with it or makes some fail); without `accept`, what it lists |
| `ui.documents` | `action?`: `new`, `switch`, `close`; `index?`; `force?` (close without asking) | the open designs (tabs): names, unsaved flags, the active one, a pending "save changes?" prompt |
| `ui.documents` | `action?`: `new`, `switch`, `close`, `quit`; `index?`; `force?` (close without asking); `answer?`: `save`, `dont_save`, `cancel` (answers "save changes?") | the open designs (tabs): names, unsaved flags, the active one, a pending "save changes?" prompt, whether a quit is asking |
| `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs |
| `ui.shortcut` | `command`, `key?`, `replace?` | a command's key, or rebinds it (`""` clears; a key another command uses is refused unless `replace`) |
| `ui.help` | `item?`: `about`, `shortcuts` (read only), `report` (copies diagnostics) | the version, commit, build date and diagnostic text |
| `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`), `zoom_reverse`, `orbit_cursor`, `msaa`, `length_decimals`, `angle_decimals` | sets preferences (kept between runs), returns them all |
| `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits |
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar) |
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first |
| `ui.resize` | `width`, `height` | |
| `app.quit` | — | |
| `app.quit` | `force?` (quit without asking about unsaved designs) | `quitting` (the window closes), `asking` (a "save changes?" prompt is open) |
## MCP
+3 -1
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 MiB

+123 -10
View File
@@ -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="")
+1 -1
View File
@@ -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.*