fix: load designs off-thread and cache recent thumbnails as PNGs
This commit is contained in:
1 parent
b375c2a92a
commit
4b7e1d317d
23 files changed
+688
-29
No files matched your search
@@ -3,7 +3,7 @@
|
||||
//! ```text
|
||||
//! solvecraft-cli run <script.json|design.solvecraft|part.step|mesh.3mf> [--in design|part.step|mesh.3mf] [--out FILE]... [--save FILE] [--quiet]
|
||||
//! solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect) measurements as JSON
|
||||
//! solvecraft-cli bench-open <design.solvecraft> [--parse-only] loading timings as JSON lines
|
||||
//! solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only] loading timings as JSON lines
|
||||
//! solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|top|…]
|
||||
//! solvecraft-cli exec <command> [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 <script.json|design.solvecraft|part.step> [--in design.solvecraft|part.step] [--out FILE]... [--save FILE] [--quiet] [--results]
|
||||
solvecraft-cli run --in <design.solvecraft|part.step> [--out FILE]...
|
||||
solvecraft-cli eval <script.json|design.solvecraft|part.step> (alias: inspect; or --in FILE)
|
||||
solvecraft-cli bench-open <design.solvecraft> [--parse-only]
|
||||
solvecraft-cli bench-open <design.solvecraft | part.step> [--parse-only]
|
||||
solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
|
||||
solvecraft-cli exec <command> [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 <design.solvecraft> [--parse-only]")?;
|
||||
let path = args.first().ok_or("usage: solvecraft-cli bench-open <design.solvecraft | part.step> [--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") {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -19,7 +19,9 @@ pub struct ModelBody {
|
||||
pub body: Body,
|
||||
/// Feature that created it.
|
||||
pub feature: u64,
|
||||
mesh: OnceLock<Arc<Mesh>>,
|
||||
// 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<OnceLock<Arc<Mesh>>>,
|
||||
/// 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<Mesh> {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -13,6 +13,8 @@ pub(crate) type Solid = mt::Solid;
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Body {
|
||||
pub(crate) solid: std::sync::Arc<Solid>,
|
||||
/// 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<std::sync::Arc<crate::meshbody::TriMesh>>,
|
||||
/// 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::<f64>()))?;
|
||||
let (v, missing) = guard("orient", || {
|
||||
let shells = mesh_shells(&solid, tol);
|
||||
let volume = shells.iter().map(|s| s.to_polygon().volume()).sum::<f64>();
|
||||
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) {
|
||||
|
||||
@@ -101,7 +101,14 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
|
||||
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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<mt::Face>) -> 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<mt::Face>) -> Result<Body> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<egui::ColorImage>,
|
||||
}
|
||||
type Job = Result<Made, String>;
|
||||
struct Running {
|
||||
request: Request,
|
||||
base: Arc<Document>,
|
||||
before: Vec<String>,
|
||||
rx: Receiver<Job>,
|
||||
phase: Arc<AtomicU8>,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct FileLoad {
|
||||
queued: VecDeque<Request>,
|
||||
running: Option<Running>,
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
+164
-5
@@ -31,11 +31,17 @@ pub struct HomeState {
|
||||
queued: Vec<String>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
rx: Option<std::sync::mpsc::Receiver<Made>>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
remembering: Option<std::sync::mpsc::Receiver<Made>>,
|
||||
/// A sample picked before its design was ready.
|
||||
wanted: Option<usize>,
|
||||
}
|
||||
|
||||
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<egui::ColorImage> {
|
||||
(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<std::path::PathBuf> {
|
||||
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<egui::ColorImage> {
|
||||
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<egui::ColorImage> {
|
||||
#[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<std::path::PathBuf>) -> Option<egui::ColorImage> {
|
||||
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<u8> = 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<egui::ColorImage>) {
|
||||
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::<usize>().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<Made> = 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();
|
||||
|
||||
@@ -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<Value, String> {
|
||||
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);
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
{
|
||||
"widget": "Insert STEP\u2026"
|
||||
},
|
||||
{"until": {"file_busy": false}},
|
||||
{
|
||||
"debug": 1
|
||||
},
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user