Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3966b8b3b9 | ||
|
|
bd8e8c4809 | ||
|
|
4562c8e5ba | ||
|
|
d2bc8e9847 | ||
|
|
8601611d0c |
No files matched your search
@@ -1,7 +1,20 @@
|
|||||||
SolveCraft is an open-source parametric 3D CAD application in pure Rust: sketch on a plane,
|
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
|
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
|
parameter to watch the timeline rebuild. This is a **pre-release**: it is early, and we'd like
|
||||||
early, and we'd like your bug reports.
|
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/
|
- **Documentation:** https://bherbruck.github.io/solvecraft/
|
||||||
- **Try it in the browser:** https://bherbruck.github.io/solvecraft/app/ (`?sample` opens a sample part)
|
- **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 |
|
| File | What |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `solvecraft-v0.1.0-windows-x86_64.zip` | Windows 10/11: `solvecraft.exe` (the app) and `solvecraft-cli.exe` |
|
| `solvecraft-v0.1.1-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.1-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.1-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.1-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-web.zip` | the web app, to host yourself (any static server; HTTPS or localhost for saving files) |
|
||||||
| `SHA256SUMS` | checksums: `sha256sum -c SHA256SUMS` |
|
| `SHA256SUMS` | checksums: `sha256sum -c SHA256SUMS` |
|
||||||
|
|
||||||
### The binaries are not signed
|
### The binaries are not signed
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Generated-in-code assets are original and have no file to list:
|
|||||||
| File | Author | Source | Licence |
|
| File | Author | Source | Licence |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| docs/screenshots/sample-plate.png | SolveCraft contributors | screenshot of SolveCraft itself (`solvecraft --sample`) | MIT OR Apache-2.0 |
|
| 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/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/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 |
|
| 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
@@ -3059,7 +3059,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "solvecraft"
|
name = "solvecraft"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"eframe",
|
"eframe",
|
||||||
"egui",
|
"egui",
|
||||||
@@ -3072,7 +3072,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "solvecraft-cli"
|
name = "solvecraft-cli"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"solvecraft-engine",
|
"solvecraft-engine",
|
||||||
@@ -3081,7 +3081,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "solvecraft-doc"
|
name = "solvecraft-doc"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -3095,7 +3095,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "solvecraft-engine"
|
name = "solvecraft-engine"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -3111,7 +3111,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "solvecraft-geom"
|
name = "solvecraft-geom"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -3119,7 +3119,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "solvecraft-io"
|
name = "solvecraft-io"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quick-xml 0.38.4",
|
"quick-xml 0.38.4",
|
||||||
"solvecraft-doc",
|
"solvecraft-doc",
|
||||||
@@ -3131,7 +3131,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "solvecraft-kernel"
|
name = "solvecraft-kernel"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -3146,7 +3146,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "solvecraft-mcp"
|
name = "solvecraft-mcp"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"solvecraft-engine",
|
"solvecraft-engine",
|
||||||
@@ -3154,7 +3154,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "solvecraft-render"
|
name = "solvecraft-render"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"image",
|
"image",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -3163,7 +3163,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "solvecraft-sketch"
|
name = "solvecraft-sketch"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -3174,7 +3174,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "solvecraft-ui-egui"
|
name = "solvecraft-ui-egui"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"egui",
|
"egui",
|
||||||
"egui-wgpu",
|
"egui-wgpu",
|
||||||
@@ -3188,7 +3188,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "solvecraft-web"
|
name = "solvecraft-web"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"eframe",
|
"eframe",
|
||||||
"egui",
|
"egui",
|
||||||
@@ -4487,7 +4487,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xtask"
|
name = "xtask"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
|||||||
exclude = ["vendor"]
|
exclude = ["vendor"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
rust-version = "1.90"
|
rust-version = "1.90"
|
||||||
|
|||||||
@@ -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
|
timeline rebuild. It is a clean-room implementation (see [CLAUDE.md](CLAUDE.md)) and part of the
|
||||||
storytold "craft" family.
|
storytold "craft" family.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
*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,
|
**Documentation:** the [SolveCraft book](https://bherbruck.github.io/solvecraft/) (user guide,
|
||||||
developer guide and command reference; sources in [docs/book](docs/book)), and the
|
developer guide and command reference; sources in [docs/book](docs/book)), and the
|
||||||
|
|||||||
@@ -29,4 +29,5 @@ web-sys = { version = "0.3", features = [
|
|||||||
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
|
"Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
|
||||||
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
|
"FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
|
||||||
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
|
"WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
|
||||||
|
"BeforeUnloadEvent", "Event", "EventTarget",
|
||||||
] }
|
] }
|
||||||
@@ -27,6 +27,25 @@ mod web {
|
|||||||
/// Seconds between autosaves of unsaved work.
|
/// Seconds between autosaves of unsaved work.
|
||||||
const AUTOSAVE_S: f64 = 30.0;
|
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 {
|
struct Shell {
|
||||||
app: SolveApp,
|
app: SolveApp,
|
||||||
files: Rc<Files>,
|
files: Rc<Files>,
|
||||||
@@ -291,6 +310,7 @@ mod web {
|
|||||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
self.app.logic(ctx);
|
self.app.logic(ctx);
|
||||||
self.files_tick(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) {
|
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||||
files::take_drops(&self.files, raw);
|
files::take_drops(&self.files, raw);
|
||||||
@@ -317,7 +337,9 @@ mod web {
|
|||||||
pub fn start() {
|
pub fn start() {
|
||||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||||
wasm_bindgen_futures::spawn_local(async {
|
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 {
|
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}\">");
|
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ struct App(SolveApp);
|
|||||||
|
|
||||||
impl eframe::App for App {
|
impl eframe::App for App {
|
||||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
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);
|
self.0.logic(ctx);
|
||||||
if self.0.quit_requested {
|
if self.0.quit_requested {
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||||
|
|||||||
@@ -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},
|
"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,
|
"home": app.home.open,
|
||||||
"documents": app.docs.count(),
|
"documents": app.docs.count(),
|
||||||
|
"quit_requested": app.quit_requested,
|
||||||
|
"quitting": app.docs.quitting.is_some(),
|
||||||
})),
|
})),
|
||||||
"ui.documents" => ok(crate::documents::control(app, p)),
|
"ui.documents" => ok(crate::documents::control(app, p)),
|
||||||
"ui.home" => {
|
"ui.home" => {
|
||||||
@@ -459,9 +461,16 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
"app.quit" => {
|
"app.quit" => {
|
||||||
app.quit_requested = true;
|
// `force`: quit without asking about unsaved designs.
|
||||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
if p.get("force").and_then(Value::as_bool).unwrap_or(false) {
|
||||||
ok(Value::Null)
|
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}`")),
|
other => err(format!("unknown method `{other}`")),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -363,7 +363,11 @@ impl Dialog {
|
|||||||
}
|
}
|
||||||
"solid.pattern.rectangular" => Dialog::new(
|
"solid.pattern.rectangular" => Dialog::new(
|
||||||
Kind::PatternRect { count: "3".into(), spacing: "20 mm".into(), count2: "1".into(), spacing2: "20 mm".into() },
|
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(
|
"solid.pattern.circular" => Dialog::new(
|
||||||
Kind::PatternCirc { count: "6".into(), angle: "360 deg".into() },
|
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.
|
/// A value field; true when Enter was pressed in it.
|
||||||
pub(crate) fn field(ui: &mut egui::Ui, s: &mut String) -> bool {
|
pub(crate) fn field(ui: &mut egui::Ui, s: &mut String) -> bool {
|
||||||
let r = ui.text_edit_singleline(s);
|
let r = ui.text_edit_singleline(s);
|
||||||
|
publish_row(&r);
|
||||||
crate::params_dialog::complete(ui, &r, s);
|
crate::params_dialog::complete(ui, &r, s);
|
||||||
r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))
|
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) {
|
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() {
|
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 {
|
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.
|
/// A row label in the dialog's label column.
|
||||||
pub(crate) fn row_label(ui: &mut egui::Ui, text: &str) {
|
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.allocate_ui_with_layout(vec2(LABEL_W, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||||
ui.set_width(LABEL_W);
|
ui.set_width(LABEL_W);
|
||||||
ui.add(egui::Label::new(text).truncate()).on_hover_text(text);
|
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 {
|
let (kind, mut inputs) = match ty {
|
||||||
0 => (
|
0 => (
|
||||||
Kind::PatternRect { count: "3".into(), spacing: "20 mm".into(), count2: "1".into(), spacing2: "20 mm".into() },
|
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 => (
|
1 => (
|
||||||
Kind::PatternCirc { count: "6".into(), angle: "360 deg".into() },
|
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)
|
.stroke(stroke)
|
||||||
.min_size(vec2(0.0, 22.0))
|
.min_size(vec2(0.0, 22.0))
|
||||||
.sense(egui::Sense::CLICK);
|
.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);
|
activate = Some(i);
|
||||||
}
|
}
|
||||||
if !inp.items.is_empty() && ui.small_button("×").on_hover_text("Clear the selection").clicked() {
|
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 }))
|
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 })
|
.fill(if valid { t.accent } else { Color32::TRANSPARENT })
|
||||||
.min_size(vec2(64.0, 24.0));
|
.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;
|
ok = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1898,9 +1925,9 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
|||||||
need(0, "objects")?;
|
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 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) {
|
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, 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});
|
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)) {
|
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)?));
|
let (pa, pb) = (ss.plane.to_world(ss.sketch.point(a)?), ss.plane.to_world(ss.sketch.point(b)?));
|
||||||
Some((pa, (pb - pa).normalized()?))
|
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,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+168
-20
@@ -1,7 +1,8 @@
|
|||||||
//! Several open designs, as tabs in the application bar. The active design lives in the app as
|
//! 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,
|
//! 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.
|
//! 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 egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -48,6 +49,8 @@ pub struct Documents {
|
|||||||
pub active: usize,
|
pub active: usize,
|
||||||
/// A tab waiting for the answer to "save changes?".
|
/// A tab waiting for the answer to "save changes?".
|
||||||
pub closing: Option<usize>,
|
pub closing: Option<usize>,
|
||||||
|
/// Quitting: the tabs answered so far with Don't Save (`None`: not quitting).
|
||||||
|
pub quitting: Option<Vec<usize>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Documents {
|
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 {
|
fn dirty(app: &SolveApp, i: usize) -> bool {
|
||||||
if i == app.docs.active || app.docs.slots.is_empty() {
|
if i == app.docs.active || app.docs.slots.is_empty() {
|
||||||
return app.session.is_dirty();
|
return app.session.is_dirty();
|
||||||
@@ -236,18 +277,28 @@ pub fn tabs_info(app: &SolveApp) -> Vec<(String, bool)> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The control channel's `ui.documents`: list, `new`, `switch` or `close` (by index; `force`
|
/// The control channel's `ui.documents`: list, `new`, `switch`, `close` (by index; `force`
|
||||||
/// closes without asking).
|
/// closes without asking) or `quit`; `answer`: save | dont_save | cancel answers "save changes?".
|
||||||
pub fn control(app: &mut SolveApp, p: &Value) -> Value {
|
pub fn control(app: &mut SolveApp, p: &Value) -> Value {
|
||||||
let i = p.get("index").and_then(Value::as_u64).map(|x| x as usize);
|
let i = p.get("index").and_then(Value::as_u64).map(|x| x as usize);
|
||||||
match p.get("action").and_then(Value::as_str) {
|
match p.get("action").and_then(Value::as_str) {
|
||||||
Some("new") => new_design(app),
|
Some("new") => new_design(app),
|
||||||
Some("switch") => switch(app, i.unwrap_or(app.docs.active)),
|
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("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();
|
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,
|
/// 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)
|
(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) {
|
pub fn prompt(app: &mut SolveApp, ctx: &egui::Context) {
|
||||||
|
quit_step(app);
|
||||||
let Some(i) = app.docs.closing else { return };
|
let Some(i) = app.docs.closing else { return };
|
||||||
let name = tabs_info(app).get(i).map(|x| x.0.clone()).unwrap_or_default();
|
let name = tabs_info(app).get(i).map(|x| x.0.clone()).unwrap_or_default();
|
||||||
let mut answer: Option<u8> = None;
|
let mut answer: Option<Answer> = None;
|
||||||
crate::frame::window(ctx, "Save changes?", crate::frame::Width::Normal)
|
crate::frame::window(ctx, format!("Save changes to {name}?"), crate::frame::Width::Normal)
|
||||||
.id(egui::Id::new("sc_close_prompt"))
|
.id(egui::Id::new("sc_close_prompt"))
|
||||||
.collapsible(false)
|
.collapsible(false)
|
||||||
.resizable(false)
|
.resizable(false)
|
||||||
.anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
|
.anchor(Align2::CENTER_CENTER, vec2(0.0, 0.0))
|
||||||
.show(ctx, |ui| {
|
.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.add_space(8.0);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui.button("Save").clicked() {
|
if ui.button("Save").clicked() {
|
||||||
answer = Some(0);
|
answer = Some(Answer::Save);
|
||||||
}
|
}
|
||||||
if ui.button("Don't Save").clicked() {
|
if ui.button("Don't Save").clicked() {
|
||||||
answer = Some(1);
|
answer = Some(Answer::DontSave);
|
||||||
}
|
}
|
||||||
if ui.button("Cancel").clicked() {
|
if ui.button("Cancel").clicked() {
|
||||||
answer = Some(2);
|
answer = Some(Answer::Cancel);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
if ctx.input(|x| x.key_pressed(egui::Key::Escape)) {
|
if ctx.input(|x| x.key_pressed(egui::Key::Escape)) {
|
||||||
answer = Some(2);
|
answer = Some(Answer::Cancel);
|
||||||
app.esc_handled = true;
|
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 {
|
match answer {
|
||||||
Some(0) => {
|
Answer::Save => {
|
||||||
switch(app, i);
|
switch(app, i);
|
||||||
crate::toolbar::save(app);
|
crate::toolbar::save(app);
|
||||||
// Saved (not cancelled in the file picker): close it.
|
let saved = !app.session.is_dirty();
|
||||||
if !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);
|
close(app, app.docs.active, true);
|
||||||
} else {
|
|
||||||
app.docs.closing = None;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(1) => close(app, i, true),
|
Answer::DontSave => {
|
||||||
Some(2) => app.docs.closing = None,
|
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)]
|
#[cfg(test)]
|
||||||
@@ -473,6 +555,72 @@ mod tests {
|
|||||||
assert!(active_is_blank(&a) && a.home.open);
|
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.
|
/// A design built elsewhere replaces a blank one and gets a tab otherwise.
|
||||||
#[test]
|
#[test]
|
||||||
fn adopt_reuses_a_blank_design() {
|
fn adopt_reuses_a_blank_design() {
|
||||||
|
|||||||
@@ -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.
|
/// Opaque model surfaces: cut by the section plane, the inside shows as a flat cap colour.
|
||||||
@fragment
|
@fragment
|
||||||
fn fs_solid(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
|
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 (clipped(i.wp)) { discard; }
|
||||||
if (!front && length(u.clip.xyz) > 0.5) {
|
if (!front && length(u.clip.xyz) > 0.5) {
|
||||||
return out_color(u.cap);
|
return out_color(u.cap);
|
||||||
}
|
}
|
||||||
if (u.ana.x > 0.5 && length(i.n) > 0.5) {
|
if (u.ana.x > 0.5 && length(i.n) > 0.5) {
|
||||||
return analysis(i);
|
return analysis(i, k);
|
||||||
}
|
}
|
||||||
return shade(i);
|
return shade(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Surface analysis colours: zebra stripes, draft angle bands, curvature map.
|
/// How fast the normal turns across the pixel (per mm).
|
||||||
fn analysis(i: TOut) -> vec4<f32> {
|
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);
|
let v = normalize(u.back.xyz);
|
||||||
var n = normalize(i.n);
|
var n = normalize(i.n);
|
||||||
if (dot(n, v) < 0.0) { n = -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));
|
return out_color(vec4<f32>(c * lit, 1.0));
|
||||||
}
|
}
|
||||||
// Curvature from how fast the normal turns across the pixel.
|
// Curvature from how fast the normal turns across the pixel.
|
||||||
let nn = normalize(i.n);
|
let t = clamp(k * u.ana.y, 0.0, 1.0);
|
||||||
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 c = select(mix(vec3<f32>(0.10, 0.80, 0.25), vec3<f32>(0.90, 0.15, 0.10), t * 2.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);
|
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));
|
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
|
@fragment
|
||||||
fn fs_img(i: IOut) -> @location(0) vec4<f32> {
|
fn fs_img(i: IOut) -> @location(0) vec4<f32> {
|
||||||
if (clipped(i.wp)) { discard; }
|
|
||||||
let c = textureSample(img_t, img_s, i.uv);
|
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));
|
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)));
|
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();
|
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())]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 {
|
if !open {
|
||||||
app.params = None;
|
app.params = None;
|
||||||
return;
|
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);
|
te = te.text_color(t.error);
|
||||||
}
|
}
|
||||||
let resp = ui.add_sized([170.0, 20.0], te);
|
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);
|
let completed = complete(ui, &resp, &mut e);
|
||||||
if resp.changed() || completed {
|
if resp.changed() || completed {
|
||||||
d.editing.insert(r.name.clone(), e.clone());
|
d.editing.insert(r.name.clone(), e.clone());
|
||||||
|
|||||||
@@ -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"]));
|
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" => {
|
"documents" => {
|
||||||
if ui["documents"] != *v {
|
if ui["documents"] != *v {
|
||||||
return Err(format!("open designs: got {}, want {v}", ui["documents"]));
|
return Err(format!("open designs: got {}, want {v}", ui["documents"]));
|
||||||
|
|||||||
@@ -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)),
|
"restore" => ctx.send_viewport_cmd(ViewportCommand::Maximized(false)),
|
||||||
"toggle" => toggle_maximize(ctx),
|
"toggle" => toggle_maximize(ctx),
|
||||||
"close" => {
|
"close" => {
|
||||||
app.quit_requested = true;
|
// Unsaved designs are asked about first; the window closes once they are answered.
|
||||||
ctx.send_viewport_cmd(ViewportCommand::Close);
|
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)")),
|
other => return Err(format!("unknown window action `{other}` (minimize, maximize, restore, toggle, close)")),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
|
|||||||
close = true;
|
close = true;
|
||||||
}
|
}
|
||||||
if item(ui, "Quit", "") {
|
if item(ui, "Quit", "") {
|
||||||
app.quit_requested = true;
|
crate::documents::request_quit(app);
|
||||||
close = true;
|
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}}
|
||||||
|
]
|
||||||
@@ -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.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.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.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.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.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.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.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.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` | |
|
| `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
|
## MCP
|
||||||
|
|
||||||
|
|||||||
+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
|
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,
|
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
|
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
|
## A typical session
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.4 MiB |
@@ -1,9 +1,13 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Demo v2: build an electronics enclosure in a running SolveCraft the way a person would, with
|
"""Demo v2/v3: build an electronics enclosure in a running SolveCraft the way a person would,
|
||||||
the parametric steps sent over MCP.
|
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 &
|
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
|
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
|
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
|
from enclosure_mcp import Mcp, control # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# Seconds a caption stays up at least.
|
||||||
|
MIN_CAPTION = 1.4
|
||||||
|
|
||||||
|
|
||||||
class Demo:
|
class Demo:
|
||||||
def __init__(self, port, mcp, log, speed):
|
def __init__(self, port, mcp, log, speed):
|
||||||
self.port, self.mcp, self.log, self.speed = 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
|
self.n = getattr(self, "n", 0) + 1
|
||||||
if self.n > self.stop:
|
if self.n > self.stop:
|
||||||
raise StopIteration
|
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)
|
print(f" {text} [{via}]", file=sys.stderr)
|
||||||
self.mark(caption=text, via=via)
|
self.mark(caption=text, via=via)
|
||||||
|
|
||||||
@@ -61,7 +74,7 @@ class Demo:
|
|||||||
p = self.ui("ui.at", where)
|
p = self.ui("ui.at", where)
|
||||||
return (p[0], p[1])
|
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
|
"""Move the pointer to `to` along an eased path (the app sees every step: hover
|
||||||
highlights follow it)."""
|
highlights follow it)."""
|
||||||
frm = self.pos or (1150.0, 620.0)
|
frm = self.pos or (1150.0, 620.0)
|
||||||
@@ -75,7 +88,7 @@ class Demo:
|
|||||||
time.sleep(dur / n)
|
time.sleep(dur / n)
|
||||||
self.pos = to
|
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.glide(to, dur)
|
||||||
self.ui("ui.click", {"x": to[0], "y": to[1]})
|
self.ui("ui.click", {"x": to[0], "y": to[1]})
|
||||||
self.mark(cursor=list(to), click=True)
|
self.mark(cursor=list(to), click=True)
|
||||||
@@ -101,6 +114,7 @@ class Demo:
|
|||||||
def view(self, v, settle=0.7):
|
def view(self, v, settle=0.7):
|
||||||
self.ui("ui.view", {"view": v, "animate": True})
|
self.ui("ui.view", {"view": v, "animate": True})
|
||||||
self.wait(settle)
|
self.wait(settle)
|
||||||
|
self.settle_camera()
|
||||||
|
|
||||||
def orbit(self, dx, dy=0, steps=40):
|
def orbit(self, dx, dy=0, steps=40):
|
||||||
"""A right-button drag in the viewport, as a user orbits."""
|
"""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.mark(drag_end=[cx + dx, cy + dy])
|
||||||
self.pos = (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):
|
def settle_camera(self):
|
||||||
"""Wait until the app has played queued pointer events (the camera stops moving)."""
|
"""Wait until the app has played queued pointer events (the camera stops moving)."""
|
||||||
last, still = None, 0
|
last, still = None, 0
|
||||||
@@ -129,9 +153,9 @@ class Demo:
|
|||||||
self.mcp.tool("execute", {"command": command, "params": params})
|
self.mcp.tool("execute", {"command": command, "params": params})
|
||||||
|
|
||||||
|
|
||||||
def run(d):
|
def build_box(d, params):
|
||||||
d.caption("Parameters over MCP: width 80, depth 60, height 30, wall 2", "MCP tools/call execute parameters.change")
|
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 [("width", "80 mm"), ("depth", "60 mm"), ("height", "30 mm"), ("wall", "2 mm")]:
|
for name, expr in params:
|
||||||
d.execute("parameters.change", {"name": name, "expression": expr})
|
d.execute("parameters.change", {"name": name, "expression": expr})
|
||||||
d.wait(0.25)
|
d.wait(0.25)
|
||||||
d.wait(0.6)
|
d.wait(0.6)
|
||||||
@@ -139,6 +163,7 @@ def run(d):
|
|||||||
d.caption("Create Sketch on the XY plane", "UI click")
|
d.caption("Create Sketch on the XY plane", "UI click")
|
||||||
d.tool("sketch.create")
|
d.tool("sketch.create")
|
||||||
d.click(d.at(plane="XY"), settle=0.8)
|
d.click(d.at(plane="XY"), settle=0.8)
|
||||||
|
d.settle_camera()
|
||||||
|
|
||||||
d.caption("Center rectangle from the origin", "UI click")
|
d.caption("Center rectangle from the origin", "UI click")
|
||||||
d.tool("sketch.rectangle.center")
|
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.caption("Sketch on the side face for a USB port", "UI click")
|
||||||
d.tool("sketch.create")
|
d.tool("sketch.create")
|
||||||
d.click(d.at(world=[40, 10, 20]), settle=0.9)
|
d.click(d.at(world=[40, 10, 20]), settle=0.9)
|
||||||
|
d.settle_camera()
|
||||||
d.tool("sketch.rectangle.center")
|
d.tool("sketch.rectangle.center")
|
||||||
d.click(d.at(world=[40, 0, 12]), settle=0.15)
|
d.click(d.at(world=[40, 0, 12]), settle=0.15)
|
||||||
d.click(d.at(world=[40, 6, 15.5]), dur=0.4, settle=0.2)
|
d.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.wait(0.6)
|
||||||
d.orbit(-140, 30)
|
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.caption("Sketch on the inner floor: four standoffs", "UI click")
|
||||||
d.tool("sketch.create")
|
d.tool("sketch.create")
|
||||||
d.click(d.at(world=[6, -9, 2]), settle=0.9)
|
d.click(d.at(world=[6, -9, 2]), settle=0.9)
|
||||||
|
d.settle_camera()
|
||||||
d.tool("sketch.circle.center")
|
d.tool("sketch.circle.center")
|
||||||
for x, y in [(-30, -20), (30, -20), (30, 20), (-30, 20)]:
|
for x, y in [(-30, -20), (30, -20), (30, 20), (-30, 20)]:
|
||||||
d.click(d.at(world=[x, y, 2]), dur=0.3, settle=0.1)
|
d.click(d.at(world=[x, y, 2]), dur=0.3, settle=0.1)
|
||||||
@@ -224,13 +254,96 @@ def run(d):
|
|||||||
d.view("home", 1.5)
|
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():
|
def main():
|
||||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
ap.add_argument("--port", type=int, default=7878, help="the app's --control port")
|
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("--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("--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("--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()
|
a = ap.parse_args()
|
||||||
mcp = Mcp([a.cli, "mcp", "--connect", f"127.0.0.1:{a.port}"])
|
mcp = Mcp([a.cli, "mcp", "--connect", f"127.0.0.1:{a.port}"])
|
||||||
mcp.send("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "enclosure-demo-v2", "version": "2"}})
|
mcp.send("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "enclosure-demo-v2", "version": "2"}})
|
||||||
@@ -239,7 +352,7 @@ def main():
|
|||||||
d.stop = a.until - 1
|
d.stop = a.until - 1
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
try:
|
try:
|
||||||
run(d)
|
run_v3(d) if a.story == "v3" else run_v2(d)
|
||||||
except StopIteration:
|
except StopIteration:
|
||||||
pass
|
pass
|
||||||
d.mark(caption="", via="")
|
d.mark(caption="", via="")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"""overlay.py LOG START OUTDIR: from enclosure_ui_mcp.py's --log (captions and pointer events)
|
"""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
|
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
|
(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 json
|
||||||
import math
|
import math
|
||||||
|
|||||||
@@ -1,27 +1,29 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# record_v2.sh DISPLAY PORT OUTDIR: record enclosure_ui_mcp.py driving the app on X display
|
# record_ui.sh DISPLAY PORT OUTDIR [STORY]: record enclosure_ui_mcp.py --story STORY (v2 or v3,
|
||||||
# DISPLAY (1920x1080, started with --control PORT) to OUTDIR/enclosure-demo-v2.mp4 (H.264) and
|
# default v3) driving the app on X display DISPLAY (1920x1080, started with --control PORT) to
|
||||||
# .gif, with captions and a drawn pointer (the app gets synthetic pointer events, so the
|
# OUTDIR/enclosure-demo-STORY.mp4 (H.264) and .gif, with captions and a drawn pointer (the app
|
||||||
# screen's own pointer never moves). Needs ffmpeg with x11grab and libass.
|
# gets synthetic pointer events, so the screen's own pointer never moves). Needs ffmpeg with
|
||||||
|
# x11grab and libass.
|
||||||
set -e
|
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)
|
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||||
CLI=${SOLVECRAFT_CLI:-solvecraft-cli}
|
CLI=${SOLVECRAFT_CLI:-solvecraft-cli}
|
||||||
# x11grab stamps frames with the wall clock: its "start:" is when the first frame was taken.
|
# 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" \
|
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" &
|
-c:v libx264 -preset veryfast -crf 16 -pix_fmt yuv420p "$OUT/raw.mp4" 2> "$OUT/ffmpeg.log" &
|
||||||
FF=$!
|
FF=$!
|
||||||
sleep 1.5
|
trap 'kill -INT $FF 2>/dev/null || true' EXIT
|
||||||
python3 "$HERE/enclosure_ui_mcp.py" --port "$PORT" --cli "$CLI" --log "$OUT/steps.log"
|
sleep 1
|
||||||
sleep 1.5
|
python3 "$HERE/enclosure_ui_mcp.py" --port "$PORT" --cli "$CLI" --story "$STORY" --log "$OUT/steps.log"
|
||||||
|
sleep 1
|
||||||
kill -INT $FF; wait $FF || true
|
kill -INT $FF; wait $FF || true
|
||||||
START=$(grep -o "start: [0-9.]*" "$OUT/ffmpeg.log" | head -1 | cut -d" " -f2)
|
START=$(grep -o "start: [0-9.]*" "$OUT/ffmpeg.log" | head -1 | cut -d" " -f2)
|
||||||
python3 "$HERE/overlay.py" "$OUT/steps.log" "$START" "$OUT"
|
python3 "$HERE/overlay.py" "$OUT/steps.log" "$START" "$OUT"
|
||||||
read CX CY < "$OUT/cursor.start"
|
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 \
|
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]" \
|
"[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"
|
-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-v2.mp4" \
|
ffmpeg -loglevel error -y -i "$OUT/enclosure-demo-$STORY.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" \
|
-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-v2.gif"
|
"$OUT/enclosure-demo-$STORY.gif"
|
||||||
ls -la "$OUT"/enclosure-demo-v2.*
|
ls -la "$OUT"/enclosure-demo-$STORY.*
|
||||||
Reference in new issue
Block a user