diff --git a/apps/solvecraft-cli/src/main.rs b/apps/solvecraft-cli/src/main.rs index 093ef200..47a9fe97 100644 --- a/apps/solvecraft-cli/src/main.rs +++ b/apps/solvecraft-cli/src/main.rs @@ -3,7 +3,7 @@ //! ```text //! solvecraft-cli run [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet] //! solvecraft-cli eval (alias: inspect) measurements as JSON -//! solvecraft-cli bench-open [--parse-only] loading timings as JSON lines +//! solvecraft-cli bench-open [--parse-only] loading timings as JSON lines //! solvecraft-cli snapshot --out shot.png [--width W] [--height H] [--view iso|front|top|…] //! solvecraft-cli exec [json-params] run one command on an empty design //! solvecraft-cli commands the command registry as JSON @@ -32,7 +32,7 @@ const USAGE: &str = "usage: solvecraft-cli run [--in design.solvecraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results] solvecraft-cli run --in [--out FILE]... solvecraft-cli eval (alias: inspect; or --in FILE) - solvecraft-cli bench-open [--parse-only] + solvecraft-cli bench-open [--parse-only] solvecraft-cli snapshot --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right] solvecraft-cli exec [json-params] solvecraft-cli commands @@ -357,17 +357,26 @@ fn cmd_bench_edit(args: &[String]) -> Result<(), String> { /// Separate file reading, JSON decoding/repair, timeline evaluation and display meshing. fn cmd_bench_open(args: &[String]) -> Result<(), String> { - let path = args.first().ok_or("usage: solvecraft-cli bench-open [--parse-only]")?; + let path = args.first().ok_or("usage: solvecraft-cli bench-open [--parse-only]")?; let t = std::time::Instant::now(); let len = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?.len(); - if len > solvecraft_engine::io::MAX_DESIGN_BYTES as u64 { + let step = solvecraft_engine::io::is_step_path(path); + let limit = if step { solvecraft_engine::io::MAX_STEP_BYTES } else { solvecraft_engine::io::MAX_DESIGN_BYTES }; + if len > limit as u64 { return Err(format!("{path}: file too large")); } let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; let read_ms = t.elapsed().as_secs_f64() * 1000.0; println!("{}", json!({"step": "read", "ms": read_ms, "bytes": bytes.len()})); let t = std::time::Instant::now(); - let doc = solvecraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())?; + let doc = if step { + let imported = solvecraft_engine::io::step_import_feature(&bytes, path).map_err(|e| e.to_string())?; + let mut doc = solvecraft_engine::doc::Document::new(&imported.name); + doc.add_feature(imported.kind, Some(&imported.name)).map_err(|e| e.to_string())?; + doc + } else { + solvecraft_engine::io::read_design(&bytes).map_err(|e| e.to_string())? + }; let parse_ms = t.elapsed().as_secs_f64() * 1000.0; println!("{}", json!({"step": "parse", "ms": parse_ms, "features": doc.features.len()})); if args.iter().any(|a| a == "--parse-only") { diff --git a/apps/solvecraft/src/main.rs b/apps/solvecraft/src/main.rs index a3a70162..f7e14d3f 100644 --- a/apps/solvecraft/src/main.rs +++ b/apps/solvecraft/src/main.rs @@ -191,7 +191,7 @@ fn main() -> eframe::Result { app.session.undo.clear(); } for f in &files { - app.open_path(f); + app.open_path_async(f); } // No design given: the start page (not when driven over the control channel, whose // clients expect the design view; `ui.home` shows it there). diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index b72539d2..37c6ba5b 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -19,7 +19,9 @@ pub struct ModelBody { pub body: Body, /// Feature that created it. pub feature: u64, - mesh: OnceLock>, + // Timeline snapshots are cloned before meshes are requested. Share the cell itself so + // meshing any snapshot also warms every unchanged copy of this body. + mesh: Arc>>, /// Identity of this body's content for the evaluation cache: the key of the evaluation /// that produced it (kept while later features pass it through unchanged). key: u64, @@ -33,7 +35,7 @@ pub struct ModelBody { impl ModelBody { pub fn new(name: String, body: Body, feature: u64) -> Self { - ModelBody { name, body, feature, mesh: OnceLock::new(), key: 0, names: Default::default(), component: 0, imported: None } + ModelBody { name, body, feature, mesh: Arc::new(OnceLock::new()), key: 0, names: Default::default(), component: 0, imported: None } } /// Display mesh (chord tolerance 1/1000 of the body size), computed once. pub fn mesh(&self) -> Arc { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 9c983111..6e318aec 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -357,3 +357,12 @@ fn input_sketches_lists_every_variant_with_a_sketch_field() { ); } } + +#[test] +fn timeline_clones_share_meshes_even_when_cloned_before_tessellation() { + let body = solvecraft_kernel::box_solid(Vec3::ZERO, Vec3::new(10., 10., 10.)).unwrap(); + let a = crate::eval::ModelBody::new("box".into(), body, 1); + let b = a.clone(); + assert!(std::sync::Arc::ptr_eq(&a.mesh(), &b.mesh())); + assert!(!a.mesh().triangles.is_empty()); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index beb357bb..a6907769 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -439,6 +439,42 @@ impl Session { } } + /// Refresh UI caches when adopting a session or switching tabs, without discarding its + /// already prepared world-space geometry. This does not change the document. + pub fn invalidate_view(&mut self, previous_revision: u64) { + let old = self.revision; + self.revision = old.max(previous_revision) + 1; + if let Ok(mut cache) = self.world_cache.lock() + && let Some((revision, _)) = cache.as_mut() + && *revision == old + { + *revision = self.revision; + } + } + + /// Commit commands evaluated on a scratch copy without losing this session's file, + /// saved state, view state, checkpoints or earlier undo steps. + pub fn accept_computed(&mut self, mut computed: Session) { + let previous_revision = self.revision; + self.doc = computed.doc; + self.model = computed.model; + self.world_cache = computed.world_cache; + self.undo.append(&mut computed.undo); + if self.undo.len() > MAX_UNDO { + self.undo.drain(..self.undo.len() - MAX_UNDO); + } + self.redo = computed.redo; + self.active_sketch = computed.active_sketch; + self.selection = computed.selection; + self.revision = computed.revision; + self.invalidate_view(previous_revision); + self.active_component = computed.active_component; + self.pending_moves = computed.pending_moves; + for message in computed.log { + self.echo(message); + } + } + /// Live preview: run `commands` (id, parameters) on a scratch copy and return the model /// before and after. Nothing in this session changes (document, undo history, selection). /// A `timeline.redefine` is shown even while the timeline is rolled back to the feature diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 26f63174..66523e7d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2032,3 +2032,40 @@ fn drawing_on_existing_points_connects_to_them() { let si = run(&mut s, "sketch.inspect", json!({})); assert_ne!(si["status"], "conflict", "{si}"); } + +#[test] +fn computed_import_preserves_file_and_earlier_undo_history() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10})); + s.path = Some("existing.solvecraft".into()); + s.mark_saved(); + let saved = s.doc.clone(); + let mut computed = s.scratch(); + run(&mut computed, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [30, 0, 0]})); + s.accept_computed(computed); + assert_eq!(s.path.as_deref(), Some("existing.solvecraft")); + assert!(s.is_dirty()); + assert_eq!(s.model.state().bodies.len(), 2); + run(&mut s, "edit.undo", json!({})); + assert!(std::sync::Arc::ptr_eq(&saved, &s.doc)); + assert!(!s.is_dirty()); + run(&mut s, "edit.undo", json!({})); + assert!(s.model.state().bodies.is_empty()); +} + +#[test] +fn adopting_a_computed_component_keeps_its_warm_world_geometry() { + let mut s = Session::default(); + run(&mut s, "component.create", json!({"name": "Part"})); + run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10})); + let mut computed = s.scratch(); + run(&mut computed, "solid.box", json!({"length":5,"width":5,"height":5,"corner":[30,0,0]})); + let prepared = computed.world_state(); + for b in &prepared.bodies { + let _ = b.mesh(); + } + s.accept_computed(computed); + assert!(std::sync::Arc::ptr_eq(&prepared, &s.world_state())); + s.invalidate_view(100); + assert!(std::sync::Arc::ptr_eq(&prepared, &s.world_state())); +} diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index 90e11583..41278c21 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -13,6 +13,8 @@ pub(crate) type Solid = mt::Solid; #[derive(Clone, Debug)] pub struct Body { pub(crate) solid: std::sync::Arc, + /// Missing-face check from the orientation mesh, reusable at exactly its tolerance. + pub(crate) coarse_missing: Option<(f64, usize)>, /// Set for mesh bodies (`solid` is then empty). pub(crate) mesh: Option>, /// Display colour (linear RGB 0..1) from an imported file; operations drop it. @@ -302,11 +304,24 @@ impl Body { if lo.x.is_finite() { (hi - lo).len() } else { 1.0 } }; let tol = (span * 0.01).clamp(1e-3, 1.0); - let v = guard("orient", || Ok(mesh_shells(&solid, tol).iter().map(|s| s.to_polygon().volume()).sum::()))?; + let (v, missing) = guard("orient", || { + let shells = mesh_shells(&solid, tol); + let volume = shells.iter().map(|s| s.to_polygon().volume()).sum::(); + let missing = + shells.iter().flat_map(|s| s.face_iter()).filter(|f| f.surface().is_none_or(|m| m.faces().triangle_iter().next().is_none())).count(); + Ok((volume, missing)) + })?; if v < 0.0 { solid.not(); } - Ok(Body { solid: std::sync::Arc::new(solid), mesh: None, color: None, paint: None, splits: None }) + Ok(Body { + solid: std::sync::Arc::new(solid), + coarse_missing: (v >= 0.0).then_some((tol, missing)), + mesh: None, + color: None, + paint: None, + splits: None, + }) } /// A deep copy of the solid that can be mutated without affecting other bodies. @@ -506,7 +521,13 @@ impl Body { if self.is_mesh() { return 0; } - let Ok(m) = self.tessellate((self.size() * 1e-2).max(1e-3)) else { return self.face_count() }; + let tol = (self.size() * 1e-2).max(1e-3); + if let Some((cached_tol, missing)) = self.coarse_missing + && cached_tol == tol + { + return missing; + } + let Ok(m) = self.tessellate(tol) else { return self.face_count() }; let mut has = vec![false; self.face_count()]; for f in &m.tri_face { if let Some(h) = has.get_mut(*f as usize) { diff --git a/crates/kernel/src/meshbody.rs b/crates/kernel/src/meshbody.rs index 7d2a967d..4ec210a0 100644 --- a/crates/kernel/src/meshbody.rs +++ b/crates/kernel/src/meshbody.rs @@ -101,7 +101,14 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result { t.swap(1, 2); } } - Ok(Body { solid: Arc::new(crate::body::Solid::new_unchecked(Vec::new())), mesh: Some(Arc::new(out)), color: None, paint: None, splits: None }) + Ok(Body { + solid: Arc::new(crate::body::Solid::new_unchecked(Vec::new())), + coarse_missing: None, + mesh: Some(Arc::new(out)), + color: None, + paint: None, + splits: None, + }) } /// Wind every connected piece one way: a triangle that runs a side in the same direction as @@ -185,6 +192,13 @@ impl Body { t.swap(1, 2); } } - Some(Body { solid: self.solid.clone(), mesh: Some(Arc::new(out)), color: self.color, paint: self.paint.clone(), splits: self.splits.clone() }) + Some(Body { + solid: self.solid.clone(), + coarse_missing: None, + mesh: Some(Arc::new(out)), + color: self.color, + paint: self.paint.clone(), + splits: self.splits.clone(), + }) } } diff --git a/crates/kernel/src/splitface.rs b/crates/kernel/src/splitface.rs index c3432651..2f904cd4 100644 --- a/crates/kernel/src/splitface.rs +++ b/crates/kernel/src/splitface.rs @@ -877,7 +877,10 @@ pub(crate) fn split_any(body: &Body, faces: &[usize], tool: &SplitTool) -> Resul } let shells = std::mem::take(&mut imp.shells); let solid = mt::Solid::new_unchecked(shells.into_iter().map(mt::Shell::from).collect()); - Ok((Body { solid: std::sync::Arc::new(solid), mesh: None, color: body.color, paint: None, splits: body.splits.clone() }, imp)) + Ok(( + Body { solid: std::sync::Arc::new(solid), coarse_missing: None, mesh: None, color: body.color, paint: None, splits: body.splits.clone() }, + imp, + )) }) } diff --git a/crates/kernel/src/surfaces.rs b/crates/kernel/src/surfaces.rs index 6bb96483..10be23d3 100644 --- a/crates/kernel/src/surfaces.rs +++ b/crates/kernel/src/surfaces.rs @@ -22,7 +22,14 @@ impl Body { /// A body of faces as they are (no orientation fix: a surface has no inside). fn surface_body(faces: Vec) -> Body { let shell = mt::Shell::from(faces); - Body { solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), mesh: None, color: None, paint: None, splits: None } + Body { + solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), + coarse_missing: None, + mesh: None, + color: None, + paint: None, + splits: None, + } } /// A body from faces: a solid when they close, else a surface. @@ -31,7 +38,14 @@ fn body_of(faces: Vec) -> Result { if shell.shell_condition() == mt::ShellCondition::Closed { Body::new(mt::Solid::new_unchecked(vec![shell])) } else { - Ok(Body { solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), mesh: None, color: None, paint: None, splits: None }) + Ok(Body { + solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), + coarse_missing: None, + mesh: None, + color: None, + paint: None, + splits: None, + }) } } diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 65cd107c..8eb8b6c9 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -1549,3 +1549,20 @@ fn raise_band_on_a_rounded_rim() { assert!((v - (v0 + band * 1.5)).abs() < 1e-5 * v, "outside {outside}: {v} vs {}", v0 + band * 1.5); } } + +#[test] +fn orientation_mesh_reuses_missing_face_check_at_matching_tolerance() { + for scale in [0.001, 1.0, 1000.0] { + let body = box_solid(Vec3::ZERO, Vec3::new(10., 20., 30.) * scale).unwrap(); + let tol = (body.size() * 0.01).max(1e-3); + let mesh = body.tessellate(tol).unwrap(); + let mut present = vec![false; body.face_count()]; + for face in mesh.tri_face { + present[face as usize] = true; + } + assert_eq!(body.unmeshed_faces(), present.iter().filter(|p| !**p).count()); + if scale == 1.0 { + assert_eq!(body.coarse_missing, Some((tol, 0))); + } + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index f5316b4f..7e8e2cb0 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -127,13 +127,24 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> "hover": app.viewport.hover.as_ref().map(|h| format!("{h:?}")), "renderer": if app.viewport.gpu.is_some() { "gpu" } else { "cpu" }, "frame_ms": app.frame_ms, + "file_busy": app.file_load.busy(), "build_ms": app.viewport.build_ms, "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, + "recent_thumbnails_ready": app.home.ready_recents(), "documents": app.docs.count(), "quit_requested": app.quit_requested, "quitting": app.docs.quitting.is_some(), })), + "ui.file" => { + let Some(path) = s("path") else { return err("path is required") }; + if p.get("insert").and_then(Value::as_bool).unwrap_or(false) { + app.insert_path_async(path); + } else { + app.open_path_async(path); + } + ok(json!({"busy": app.file_load.busy()})) + } "ui.documents" => ok(crate::documents::control(app, p)), "ui.home" => { match p.get("open").and_then(Value::as_bool) { diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 4de61b92..60a1ca1f 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -91,7 +91,7 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot { }; put_view(app, slot.view); // Views cache by revision: the incoming design must not look unchanged. - app.session.revision = app.session.revision.max(out_rev) + 1; + app.session.invalidate_view(out_rev); app.ui.tab = if app.session.active_sketch.is_some() { "SKETCH".into() } else { "SOLID".into() }; out } @@ -141,7 +141,7 @@ pub fn adopt(app: &mut SolveApp, session: Session) { crate::dialogs::cancel(app); app.tool = None; app.session = session; - app.session.revision = app.session.revision.max(rev) + 1; + app.session.invalidate_view(rev); app.cam = Camera::default(); app.fit_view(); } else if let Err(e) = open_tab(app, session) { diff --git a/crates/ui-egui/src/file_load.rs b/crates/ui-egui/src/file_load.rs new file mode 100644 index 00000000..5d2644d3 --- /dev/null +++ b/crates/ui-egui/src/file_load.rs @@ -0,0 +1,261 @@ +//! Interactive file operations run off the window thread. The old design stays intact until +//! the command and display meshes are ready; failures and cancelled jobs never replace it. + +use std::collections::VecDeque; +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicU8, Ordering}, + mpsc::{Receiver, TryRecvError}, +}; + +use serde_json::Value; +#[cfg(not(target_arch = "wasm32"))] +use serde_json::json; +use solvecraft_engine::{Session, doc::Document}; + +use crate::SolveApp; + +struct Request { + path: String, + insert: bool, +} +struct Made { + session: Session, + result: Value, + image: Option, +} +type Job = Result; +struct Running { + request: Request, + base: Arc, + before: Vec, + rx: Receiver, + phase: Arc, + cancelled: Arc, +} + +#[derive(Default)] +pub struct FileLoad { + queued: VecDeque, + running: Option, +} + +impl FileLoad { + pub fn busy(&self) -> bool { + self.running.as_ref().is_some_and(|r| !r.cancelled.load(Ordering::Relaxed)) || !self.queued.is_empty() + } +} + +pub fn begin(app: &mut SolveApp, path: &str, insert: bool) { + #[cfg(target_arch = "wasm32")] + { + if insert { + app.insert_path(path); + } else { + app.open_path(path); + } + } + #[cfg(not(target_arch = "wasm32"))] + { + if app.file_load.queued.len() >= crate::documents::MAX_OPEN { + app.set_status("too many queued files", true); + return; + } + app.file_load.queued.push_back(Request { path: path.into(), insert }); + start(app); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn start(app: &mut SolveApp) { + if app.file_load.running.is_some() { + return; + } + let Some(request) = app.file_load.queued.pop_front() else { return }; + if !request.insert && app.docs.count() >= crate::documents::MAX_OPEN && !crate::documents::active_is_blank(app) { + app.set_status("too many open designs; close a tab before opening another", true); + return; + } + let before = if request.insert { app.session.world_state().bodies.iter().map(|b| b.name.clone()).collect() } else { Vec::new() }; + let mut scratch = if request.insert { app.session.scratch() } else { Session::default() }; + if request.insert { + scratch.selection = app.session.selection.clone(); + } + let base = app.session.doc.clone(); + let phase = Arc::new(AtomicU8::new(0)); + let worker_phase = phase.clone(); + let cancelled = Arc::new(AtomicBool::new(false)); + let worker_cancelled = cancelled.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + let path = request.path.clone(); + let insert = request.insert; + let spawned = std::thread::Builder::new().name("file-load".into()).spawn(move || { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| compute(scratch, &path, insert, &worker_phase, &worker_cancelled))) + .unwrap_or_else(|_| Err("the file operation failed; the current design is unchanged".into())); + let _ = tx.send(result); + }); + match spawned { + Ok(_) => app.file_load.running = Some(Running { request, base, before, rx, phase, cancelled }), + Err(e) => app.set_status(format!("could not start file operation: {e}"), true), + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn compute(mut session: Session, path: &str, insert: bool, phase: &AtomicU8, cancelled: &AtomicBool) -> Job { + if cancelled.load(Ordering::Relaxed) { + return Err("file operation cancelled".into()); + } + let command = if !insert { + "doc.open" + } else if solvecraft_engine::io::is_mesh_path(path) { + "file.insert_mesh" + } else { + "file.insert_step" + }; + let result = session.execute(command, &json!({"path": path})).map_err(|e| e.to_string())?; + phase.store(1, Ordering::Relaxed); + // Fit, picking and the first viewport frame must not trigger cold tessellation on the UI. + for b in &session.world_state().bodies { + if cancelled.load(Ordering::Relaxed) { + return Err("file operation cancelled".into()); + } + let _ = b.mesh(); + } + phase.store(2, Ordering::Relaxed); + let image = if insert { None } else { crate::home::remember(&session) }; + Ok(Made { session, result, image }) +} + +pub fn tick(app: &mut SolveApp, ctx: &egui::Context) { + let result = app.file_load.running.as_ref().and_then(|r| match r.rx.try_recv() { + Ok(job) => Some(job), + Err(TryRecvError::Empty) => None, + Err(TryRecvError::Disconnected) => Some(Err("the file operation stopped; the current design is unchanged".into())), + }); + if let Some(job) = result + && let Some(running) = app.file_load.running.take() + { + match job { + _ if running.cancelled.load(Ordering::Relaxed) => {} + Ok(made) if !running.request.insert || Arc::ptr_eq(&app.session.doc, &running.base) => { + if running.request.insert { + app.session.accept_computed(made.session); + app.big_operation = true; + } else { + crate::documents::adopt(app, made.session); + app.home.add_recent(&running.request.path); + crate::home::install(&mut app.home, ctx, &running.request.path, made.image); + } + app.home.open = false; + app.fit_view(); + crate::stand_up::offer(app, &running.request.path, &running.before); + let warnings = made.result.get("warnings").and_then(Value::as_array); + app.set_status( + if let Some(w) = warnings.filter(|w| !w.is_empty()) { + format!("imported with {} warning(s): {}", w.len(), w.first().and_then(Value::as_str).unwrap_or("")) + } else { + format!("{} done", if running.request.insert { "Insert" } else { "Open" }) + }, + false, + ); + } + Ok(_) => app.set_status("the active design changed while importing; the import was discarded", true), + Err(e) => app.set_status(e, true), + } + } + #[cfg(not(target_arch = "wasm32"))] + start(app); + if app.file_load.busy() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } +} + +pub fn show(app: &mut SolveApp, ctx: &egui::Context) { + let Some(r) = &app.file_load.running else { return }; + let cancelled = r.cancelled.load(Ordering::Relaxed); + if cancelled && app.file_load.queued.is_empty() { + return; + } + let name = std::path::Path::new(&r.request.path).file_name().map(|p| p.to_string_lossy()).unwrap_or_default(); + let phase = r.phase.load(Ordering::Relaxed); + let mut cancel = false; + egui::Modal::new(egui::Id::new("file_loading")).show(ctx, |ui| { + ui.heading(if cancelled { + "Waiting for file operation" + } else if r.request.insert { + "Inserting geometry" + } else { + "Opening design" + }); + ui.label(name.as_ref()); + ui.horizontal(|ui| { + ui.spinner(); + ui.label(if cancelled { + "Waiting for the previous operation to stop..." + } else { + match phase { + 1 => "Preparing the view...", + 2 => "Preparing the thumbnail...", + _ => "Reading and building geometry...", + } + }); + }); + cancel = ui.button("Cancel").clicked(); + }); + if cancel { + // Keep the running job until it exits: cancelling must not allow another large + // import to overlap it in memory. The user can continue editing the current design. + if let Some(r) = &app.file_load.running { + r.cancelled.store(true, Ordering::Relaxed); + } + app.file_load.queued.clear(); + app.set_status("file operation cancelled", false); + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn failed_import_keeps_the_current_document() { + let mut app = SolveApp::new(Session::default(), Default::default()); + let old = app.session.doc.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + tx.send(Err("bad STEP".into())).unwrap(); + app.file_load.running = Some(Running { + request: Request { path: "bad.step".into(), insert: true }, + base: old.clone(), + before: Vec::new(), + rx, + phase: Arc::new(AtomicU8::new(0)), + cancelled: Arc::new(AtomicBool::new(false)), + }); + tick(&mut app, &egui::Context::default()); + assert!(Arc::ptr_eq(&app.session.doc, &old)); + assert!(!app.file_load.busy()); + assert!(app.status.as_ref().unwrap().0.contains("bad STEP")); + } + #[test] + fn cancelling_discards_a_finished_result_and_keeps_the_worker_slot_until_completion() { + let mut app = SolveApp::new(Session::default(), Default::default()); + let old = app.session.doc.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + let cancelled = Arc::new(AtomicBool::new(true)); + app.file_load.running = Some(Running { + request: Request { path: "cancelled.step".into(), insert: true }, + base: old.clone(), + before: Vec::new(), + rx, + phase: Arc::new(AtomicU8::new(0)), + cancelled, + }); + assert!(!app.file_load.busy()); + assert!(app.file_load.running.is_some()); + let mut computed = Session::default(); + computed.execute("solid.box", &serde_json::json!({"length":10,"width":10,"height":10})).unwrap(); + tx.send(Ok(Made { session: computed, result: Value::Null, image: None })).unwrap(); + tick(&mut app, &egui::Context::default()); + assert!(app.file_load.running.is_none()); + assert!(Arc::ptr_eq(&old, &app.session.doc)); + } +} diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index deab41d2..a0433e98 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -31,11 +31,17 @@ pub struct HomeState { queued: Vec, #[cfg(not(target_arch = "wasm32"))] rx: Option>, + #[cfg(not(target_arch = "wasm32"))] + remembering: Option>, /// A sample picked before its design was ready. wanted: Option, } impl HomeState { + pub fn ready_recents(&self) -> usize { + self.recent.iter().filter(|p| self.thumbs.get(*p).is_some_and(Option::is_some)).count() + } + pub fn show(&mut self, _rev: u64) { self.open = true; } @@ -67,8 +73,104 @@ pub fn thumbnail(s: &Session) -> Option { (c.rgba.len() == c.w * c.h * 4).then(|| egui::ColorImage::from_rgba_unmultiplied([c.w, c.h], &c.rgba)) } +/// File identity, not just its path: saving or replacing a design invalidates its picture. +#[cfg(not(target_arch = "wasm32"))] +fn cache_path(key: &str) -> Option { + use std::hash::{Hash, Hasher}; + let meta = std::fs::metadata(key).ok()?; + let mut h = std::collections::hash_map::DefaultHasher::new(); + key.hash(&mut h); + meta.len().hash(&mut h); + meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.hash(&mut h); + let root = std::env::var_os("LOCALAPPDATA") + .or_else(|| std::env::var_os("XDG_CACHE_HOME")) + .map(std::path::PathBuf::from) + .unwrap_or_else(std::env::temp_dir); + Some(root.join("SolveCraft").join("thumbnails").join(format!("{:016x}.png", h.finish()))) +} + +#[cfg(not(target_arch = "wasm32"))] +fn cached(key: &str) -> Option { + let path = cache_path(key)?; + if std::fs::metadata(&path).ok()?.len() > 512 * 1024 { + return None; + } + let bytes = std::fs::read(path).ok()?; + let mut reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), image::ImageFormat::Png); + let mut limits = image::Limits::default(); + limits.max_image_width = Some(THUMB[0] as u32); + limits.max_image_height = Some(THUMB[1] as u32); + limits.max_alloc = Some((THUMB[0] * THUMB[1] * 8) as u64); + reader.limits(limits); + let image = reader.decode().ok()?.to_rgba8(); + (image.width() as usize == THUMB[0] && image.height() as usize == THUMB[1]) + .then(|| egui::ColorImage::from_rgba_unmultiplied(THUMB, image.as_raw())) +} + +/// Render from the model already built by Open/Save, rather than evaluating its file again. +pub(crate) fn remember(s: &Session) -> Option { + #[cfg(not(target_arch = "wasm32"))] + let identity = s.path.as_deref().and_then(cache_path); + remember_at( + s, + #[cfg(not(target_arch = "wasm32"))] + identity, + ) +} + +fn remember_at(s: &Session, #[cfg(not(target_arch = "wasm32"))] identity: Option) -> Option { + let img = thumbnail(s)?; + #[cfg(not(target_arch = "wasm32"))] + if let Some(path) = identity.filter(|p| s.path.as_deref().and_then(cache_path).as_ref() == Some(p)) { + let rgba: Vec = img.pixels.iter().flat_map(|c| c.to_srgba_unmultiplied()).collect(); + if let Some(bytes) = solvecraft_engine::render::png(THUMB[0], THUMB[1], rgba) + && let Some(dir) = path.parent() + && std::fs::create_dir_all(dir).is_ok() + { + let _ = solvecraft_engine::io::write_atomic(&path, &bytes, false); + } + } + Some(img) +} + +pub(crate) fn remember_session(h: &mut HomeState, s: &Session) { + #[cfg(not(target_arch = "wasm32"))] + if let Some(path) = &s.path { + let identity = cache_path(path); + let mut snapshot = s.scratch(); + snapshot.path = Some(path.clone()); + let key = path.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + if std::thread::Builder::new() + .name("saved-thumbnail".into()) + .spawn(move || { + let img = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| remember_at(&snapshot, identity))).ok().flatten(); + let _ = tx.send((key, img, None)); + }) + .is_ok() + { + h.remembering = Some(rx); + h.queued.retain(|k| k != path); + h.thumbs.insert(path.clone(), None); + } + } + #[cfg(target_arch = "wasm32")] + { + let _ = (h, s); + } +} + +pub(crate) fn install(h: &mut HomeState, ctx: &egui::Context, key: &str, img: Option) { + let tex = img.map(|i| ctx.load_texture(format!("thumb:{key}"), i, egui::TextureOptions::LINEAR)); + h.thumbs.insert(key.to_string(), tex); +} + /// Build what a key stands for (runs off the UI thread where threads exist). fn make(key: &str) -> Made { + #[cfg(not(target_arch = "wasm32"))] + if let Some(img) = cached(key) { + return (key.to_string(), Some(img), None); + } let mut s = Session::default(); let built = match key.strip_prefix("sample:").and_then(|i| i.parse::().ok()) { Some(i) => solvecraft_engine::sample::samples().get(i).is_some_and(|smp| { @@ -83,7 +185,7 @@ fn make(key: &str) -> Made { if !built { return (key.to_string(), None, None); } - let img = thumbnail(&s); + let img = remember(&s); (key.to_string(), img, key.starts_with("sample:").then_some(s)) } @@ -92,7 +194,18 @@ fn start_jobs(h: &mut HomeState) { if h.rx.is_some() || h.queued.is_empty() { return; } - let keys = std::mem::take(&mut h.queued); + let mut keys = std::mem::take(&mut h.queued); + // Cached recents first, then cold recents, then samples. A sample must never hold up + // pictures of the user's work; a cold file must not hold up already cached pictures. + keys.sort_by_key(|k| { + if k.starts_with("sample:") { + 2 + } else if cache_path(k).is_some_and(|p| p.exists()) { + 0 + } else { + 1 + } + }); let (tx, rx) = std::sync::mpsc::channel(); let spawned = std::thread::Builder::new().name("thumbnails".into()).spawn(move || { for k in keys { @@ -112,12 +225,31 @@ fn start_jobs(h: &mut HomeState) { fn poll(app: &mut SolveApp, ctx: &egui::Context) { let mut done: Vec = Vec::new(); #[cfg(not(target_arch = "wasm32"))] + let mut remembered = None; + #[cfg(not(target_arch = "wasm32"))] { + if let Some(rx) = &app.home.remembering { + match rx.try_recv() { + Ok(m) => { + remembered = Some(m); + app.home.remembering = None; + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => app.home.remembering = None, + Err(std::sync::mpsc::TryRecvError::Empty) => { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + } + } start_jobs(&mut app.home); if let Some(rx) = &app.home.rx { loop { match rx.try_recv() { - Ok(m) => done.push(m), + Ok(m) => { + // A cold job started before a save/open cannot overwrite its fresh picture. + if m.0.starts_with("sample:") || !app.home.thumbs.get(&m.0).is_some_and(Option::is_some) { + done.push(m); + } + } Err(std::sync::mpsc::TryRecvError::Empty) => break, Err(std::sync::mpsc::TryRecvError::Disconnected) => { app.home.rx = None; @@ -126,6 +258,9 @@ fn poll(app: &mut SolveApp, ctx: &egui::Context) { } } } + if let Some(m) = remembered { + done.push(m); + } if app.home.rx.is_some() { ctx.request_repaint_after(std::time::Duration::from_millis(100)); } @@ -185,7 +320,7 @@ pub fn open_recent(app: &mut SolveApp, path: &str) { return; } app.home.open = false; - app.open_path(path); + app.open_path_async(path); } /// The page itself (in place of the toolbar, browser and viewport). @@ -250,7 +385,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { action = Some(Box::new(|app: &mut SolveApp| { if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) { app.home.open = false; - app.open_path(&p); + app.open_path_async(&p); } })); } @@ -416,6 +551,30 @@ fn card(ui: &mut egui::Ui, pic: Pic, name: &str, sub: &str, dim: bool) -> egui:: mod tests { use super::*; + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn cached_recent_uses_a_separate_png_without_reading_or_changing_design_json() { + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!("solvecraft-thumb-{}-{nonce}.solvecraft", std::process::id())); + // Deliberately invalid design: a cached recent must not parse/evaluate it. + let bytes = b"{\"thumbnail_test\":true}"; + std::fs::write(&path, bytes).unwrap(); + let key = path.to_string_lossy().to_string(); + let mut s = Session::default(); + s.execute("solid.box", &serde_json::json!({"length":10,"width":10,"height":10})).unwrap(); + s.path = Some(key.clone()); + let image = remember(&s).unwrap(); + let png = cache_path(&key).unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + let (_, cached_image, session) = make(&key); + assert!(session.is_none()); + assert_eq!(cached_image.unwrap().pixels, image.pixels); + std::fs::write(&path, b"changed file identity").unwrap(); + assert!(cached(&key).is_none()); + std::fs::remove_file(path).unwrap(); + std::fs::remove_file(png).unwrap(); + } + #[test] fn recent_list_is_newest_first_and_bounded() { let mut h = HomeState::default(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 35a254d7..34b7b49f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -54,6 +54,7 @@ mod status_tests { assert!(app.session.log.last().is_some_and(|l| l.starts_with("Redo: ") && !l.contains("edit.redo")), "{:?}", app.session.log.last()); } } +mod file_load; pub mod palette; pub mod params_dialog; pub mod prefs; @@ -230,6 +231,7 @@ pub struct SolveApp { fitted: bool, /// The start page. pub home: home::HomeState, + pub file_load: file_load::FileLoad, /// The other open designs (tabs). pub docs: documents::Documents, } @@ -281,6 +283,7 @@ impl SolveApp { styled: false, fitted: false, home: home::HomeState::default(), + file_load: file_load::FileLoad::default(), docs: documents::Documents::default(), } } @@ -388,6 +391,9 @@ impl SolveApp { /// Run a command programmatically (JSON parameters, no dialogs). pub fn run(&mut self, id: &str, params: Value) -> Result { + if self.file_load.busy() { + return Err("a file operation is in progress".into()); + } let t0 = now_ms(); let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); self.big_operation |= now_ms() - t0 >= recovery_ui::BIG_OPERATION_MS; @@ -406,6 +412,9 @@ impl SolveApp { && let Some(p) = self.session.path.clone() { self.home.add_recent(&p); + if matches!(id, "file.save" | "file.save_as") { + home::remember_session(&mut self.home, &self.session); + } } } Err(e) => { @@ -441,7 +450,7 @@ impl SolveApp { } if matches!(id, "file.insert_step" | "file.insert_mesh") { if let Some(p) = self.services.pick_open.as_ref().and_then(|f| f()) { - self.insert_path(&p); + self.insert_path_async(&p); } return; } @@ -622,6 +631,13 @@ impl SolveApp { } } + pub fn open_path_async(&mut self, path: &str) { + file_load::begin(self, path, false); + } + pub fn insert_path_async(&mut self, path: &str) { + file_load::begin(self, path, true); + } + /// Insert a STEP file's bodies (an Import base feature) or a 3MF/STL file's meshes into the /// current design. pub fn insert_path(&mut self, path: &str) { @@ -641,9 +657,9 @@ impl SolveApp { pub fn drop_path(&mut self, path: &str) { let import = solvecraft_engine::io::is_step_path(path) || solvecraft_engine::io::is_mesh_path(path); if import && !self.session.doc.features.is_empty() { - self.insert_path(path); + self.insert_path_async(path); } else { - self.open_path(path); + self.open_path_async(path); } } @@ -733,6 +749,7 @@ impl SolveApp { /// Per-frame logic before layout. pub fn logic(&mut self, ctx: &egui::Context) { + file_load::tick(self, ctx); self.esc_handled = false; prefs::apply(self, ctx); if !self.styled || theme::is_dark() != self.ui.dark { @@ -798,6 +815,7 @@ impl SolveApp { let t0 = now_ms(); scenario::clear_handles(); toolbar::app_bar(self, ui); + file_load::show(self, ui.ctx()); documents::prompt(self, ui.ctx()); if self.home.open { home::show(self, ui); diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index 476ae7da..4508db57 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -865,7 +865,7 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> { } } } - "quit_requested" | "quitting" => { + "file_busy" | "quit_requested" | "quitting" => { if ui[k] != *v { return Err(format!("{k}: got {}, want {v}", ui[k])); } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 3bd4ccab..8e3571c6 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -161,7 +161,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) { } if item(ui, "Open…", "Ctrl+O") { if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) { - app.open_path(&p); + app.open_path_async(&p); } close = true; } @@ -531,6 +531,9 @@ fn caret(p: &egui::Painter, c: egui::Pos2, col: Color32) { /// Global keyboard shortcuts (when no text field has focus). pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { + if app.file_load.busy() { + return; + } // The Keyboard Shortcuts window is waiting for a key: it is not a shortcut. if crate::keymap::capturing(app) { return; @@ -554,7 +557,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { Key::Comma => crate::prefs::open_at(app, 0), Key::O => { if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) { - app.open_path(&p); + app.open_path_async(&p); } } _ => { diff --git a/crates/ui-egui/tests/scenarios.rs b/crates/ui-egui/tests/scenarios.rs index 0574bb20..604766c8 100644 --- a/crates/ui-egui/tests/scenarios.rs +++ b/crates/ui-egui/tests/scenarios.rs @@ -80,6 +80,7 @@ fn home_tab_and_file_recent_open_designs() { {"expect":{"home":false,"bodies":1}}, {"click":{"handle":"tab:home"}}, {"click":{"handle":"home:recent:0"}}, + {"until":{"file_busy":false}}, {"expect":{"home":false,"bodies":1,"documents":2}}, {"click":{"handle":"appbar:folder"}}, {"widget":"Open Recent"} @@ -87,6 +88,7 @@ fn home_tab_and_file_recent_open_designs() { .unwrap(); h.run(&json!([ {"widget":path}, + {"until":{"file_busy":false}}, {"expect":{"home":false,"bodies":1,"documents":3}} ])) .unwrap(); diff --git a/crates/ui-egui/tests/scenarios/s09_step_roundtrip_hole.json b/crates/ui-egui/tests/scenarios/s09_step_roundtrip_hole.json index be90b4df..46de4f33 100644 --- a/crates/ui-egui/tests/scenarios/s09_step_roundtrip_hole.json +++ b/crates/ui-egui/tests/scenarios/s09_step_roundtrip_hole.json @@ -83,6 +83,7 @@ { "widget": "Insert STEP\u2026" }, + {"until": {"file_busy": false}}, { "debug": 1 }, diff --git a/crates/ui-egui/tests/scenarios/s15_start_page_sample_save_reopen.json b/crates/ui-egui/tests/scenarios/s15_start_page_sample_save_reopen.json index f74333ca..b9b0afdc 100644 --- a/crates/ui-egui/tests/scenarios/s15_start_page_sample_save_reopen.json +++ b/crates/ui-egui/tests/scenarios/s15_start_page_sample_save_reopen.json @@ -18,6 +18,7 @@ {"expect": {"bodies": 0}}, {"choose_file": "Sample Plate.solvecraft"}, {"key": "O", "cmd": true}, + {"until": {"file_busy": false}}, {"debug": 1}, {"expect": {"bodies": 1, "errors": 0, "param": {"width": 100}}}, {"shot": "s15_reopened"} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3d4490b5..35a82c20 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -46,6 +46,7 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints | `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`, `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.file` | `path`; `insert?`: bool | starts an interactive background Open or Insert; poll `ui.inspect.file_busy` for completion; the window stays responsive | | `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 | diff --git a/docs/perf.md b/docs/perf.md index b1d29fea..89fca737 100644 --- a/docs/perf.md +++ b/docs/perf.md @@ -113,3 +113,43 @@ errors) through edits, suppression, roll-back, undo and redo. Setting a sketch d can still recompute (oracle 61, 62): solving the sketch moves its points by rounding noise, so its inputs are not exactly the old ones. Oracle 59's first parameter edit fails (a recipe expression the bench's `* 1.02` can't scale) and is left out. + + +## Desktop loading and recent thumbnails (2026-10-10) + +The Arduino Uno enclosure (19 features, 34 KB) and Raspberry Pi 5 case (24 features, +61 KB) each decode in about 1 ms. A release build spends roughly 12.1 s and 8.2 s, +respectively, evaluating their geometry on this Windows machine. Changing the document +encoding does not eliminate these boolean operations. + +Interactive Open, recent-file clicks, drag/drop and STEP/mesh insertion now execute on a +worker, including preparation of the world-space bodies and display meshes. The existing +design stays available until the result is ready; a failed or cancelled job cannot replace +it. Insertion preserves the file, saved state, earlier undo history and view settings. +Timeline copies share their lazy mesh cell, and adopting a prepared session keeps its warm +world-space cache instead of transforming and tessellating its components again on the UI. +A STEP body's missing-face check reuses its orientation tessellation when the tolerances +match and its orientation was not reversed. + +Live desktop tests (`ci` profile, with other builds/tests running) opened the Arduino and +Pi case in 15.2 s and 10.5 s. During those jobs, median `ui.inspect` round trips were 20.5 ms +and 8.3 ms; the slowest were 33.7 ms and 28.6 ms. This fixes the window freeze, not the +remaining cost of cold geometry evaluation. + +Recent thumbnails are 240 × 150 PNG files in `SolveCraft/thumbnails` under the platform +cache directory (`LOCALAPPDATA` on Windows). Their keys include the source path, file size +and modification time. Open and Save render from the model already in memory. Cached +recents decode the PNG without reading or rebuilding a document, and run before cold +recents and sample thumbnails. Missing or invalid pictures fall back to a background rebuild. +No thumbnail or base64 image is added to the design JSON. A regression test verifies the +source bytes remain unchanged, a cached picture works even without a parseable source +file, and changing the source invalidates the cache. + +`solvecraft-cli bench-open` also accepts STEP/IGES. For exchange files its `parse` phase +includes constructing and checking the imported B-rep, not merely decoding STEP syntax. + +The live test loaded both cached recent cards in 21 ms and inserted the 2 MB lightweight +Pi STEP (27 bodies) into the case in 279 ms; undo restored the original two bodies. The +full 77 MB board STEP (2689 solid definitions, 45,689 faces) still had not completed after +300 s. Its window continued handling inspection and Cancel, and cancelling preserved the +case. Complex STEP construction remains an outstanding kernel performance problem.