Opening a design shows the model built so far (#1158)
A large design showed nothing but a progress bar until it had rebuilt completely. While it rebuilds, the evaluation now offers the model built so far (placed as the design places it) every 1.5 s to a window that asks, and the open card draws it as a small picture on a thread of its own, the way it shows a saved preview. The bodies' meshes made for it are the ones the finished model shows, so little is done twice. A design that opens within the first interval shows none. Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
fd12325d90
commit
a6fbc63746
3 files changed
+203
-3
No files matched your search
@@ -1219,6 +1219,8 @@ impl Model {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
state = Arc::new(next);
|
state = Arc::new(next);
|
||||||
|
// A window opening the design can show what is built so far.
|
||||||
|
watched.offer(i + 1, doc, &state);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
Err(e) => Some(e.to_string().trim_start_matches("the operation failed: ").to_string()),
|
Err(e) => Some(e.to_string().trim_start_matches("the operation failed: ").to_string()),
|
||||||
|
|||||||
@@ -21,8 +21,23 @@ pub struct Progress {
|
|||||||
work_stage: std::sync::Mutex<&'static str>,
|
work_stage: std::sync::Mutex<&'static str>,
|
||||||
work_done: AtomicUsize,
|
work_done: AtomicUsize,
|
||||||
work_total: AtomicUsize,
|
work_total: AtomicUsize,
|
||||||
|
/// A window wants the model as far as it is built, to show while the rest is ([`Progress::want_partial`]).
|
||||||
|
partial_wanted: AtomicBool,
|
||||||
|
/// The latest such model and when it was offered.
|
||||||
|
partial: std::sync::Mutex<(Option<Partial>, Option<web_time::Instant>)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The model as far as an evaluation has built it, placed in the world as the design places it.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Partial {
|
||||||
|
/// Features evaluated.
|
||||||
|
pub features: usize,
|
||||||
|
pub world: Arc<crate::ModelState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How often an evaluation offers the model built so far.
|
||||||
|
const PARTIAL_EVERY: std::time::Duration = std::time::Duration::from_millis(1500);
|
||||||
|
|
||||||
impl Default for Progress {
|
impl Default for Progress {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Progress {
|
Progress {
|
||||||
@@ -33,6 +48,8 @@ impl Default for Progress {
|
|||||||
work_stage: std::sync::Mutex::new(""),
|
work_stage: std::sync::Mutex::new(""),
|
||||||
work_done: AtomicUsize::new(0),
|
work_done: AtomicUsize::new(0),
|
||||||
work_total: AtomicUsize::new(0),
|
work_total: AtomicUsize::new(0),
|
||||||
|
partial_wanted: AtomicBool::new(false),
|
||||||
|
partial: std::sync::Mutex::new((None, None)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,6 +71,14 @@ impl Progress {
|
|||||||
pub fn features(&self) -> (usize, usize) {
|
pub fn features(&self) -> (usize, usize) {
|
||||||
(self.done.load(Ordering::Relaxed), self.total.load(Ordering::Relaxed))
|
(self.done.load(Ordering::Relaxed), self.total.load(Ordering::Relaxed))
|
||||||
}
|
}
|
||||||
|
/// Ask the watched evaluation to offer the model as far as it is built, now and then.
|
||||||
|
pub fn want_partial(&self) {
|
||||||
|
self.partial_wanted.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
/// The model as far as it was built when last offered, once (`None` until there is a newer one).
|
||||||
|
pub fn take_partial(&self) -> Option<Partial> {
|
||||||
|
self.partial.lock().ok().and_then(|mut p| p.0.take())
|
||||||
|
}
|
||||||
/// Long work inside a feature, such as reading a large STEP file: (step, done, total);
|
/// Long work inside a feature, such as reading a large STEP file: (step, done, total);
|
||||||
/// `None` while there is none to show.
|
/// `None` while there is none to show.
|
||||||
pub fn work(&self) -> Option<(&'static str, usize, usize)> {
|
pub fn work(&self) -> Option<(&'static str, usize, usize)> {
|
||||||
@@ -146,6 +171,37 @@ impl Evaluation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Evaluation {
|
||||||
|
/// The outer evaluation has built the model up to feature `features`: a watcher that wants
|
||||||
|
/// partial models gets it, placed by `doc`, at most every [`PARTIAL_EVERY`] (the first one
|
||||||
|
/// after one interval, so a design that opens quickly offers none).
|
||||||
|
pub(crate) fn offer(&self, features: usize, doc: &crate::Document, state: &Arc<crate::ModelState>) {
|
||||||
|
if !self.outer {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let progress = CURRENT.with(|c| c.borrow().as_ref().map(|cur| cur.progress.clone()));
|
||||||
|
let Some(progress) = progress.filter(|p| p.partial_wanted.load(Ordering::Relaxed) && !p.is_cancelled()) else { return };
|
||||||
|
let now = web_time::Instant::now();
|
||||||
|
let due = match progress.partial.lock() {
|
||||||
|
Ok(mut p) => match p.1 {
|
||||||
|
None => {
|
||||||
|
p.1 = Some(now);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Some(last) => now.duration_since(last) >= PARTIAL_EVERY,
|
||||||
|
},
|
||||||
|
Err(_) => false,
|
||||||
|
};
|
||||||
|
if !due {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let world = Arc::new(crate::world_state(doc, state));
|
||||||
|
if let Ok(mut p) = progress.partial.lock() {
|
||||||
|
*p = (Some(Partial { features, world }), Some(web_time::Instant::now()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Drop for Evaluation {
|
impl Drop for Evaluation {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
CURRENT.with(|c| {
|
CURRENT.with(|c| {
|
||||||
@@ -185,4 +241,44 @@ mod tests {
|
|||||||
assert_eq!(p.features(), (5, 5));
|
assert_eq!(p.features(), (5, 5));
|
||||||
assert!(!begin(1).step(0), "the watch ends with its scope");
|
assert!(!begin(1).step(0), "the watch ends with its scope");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A watcher that asks for partial models gets the model built so far, placed, now and then;
|
||||||
|
/// one that doesn't ask, or an unwatched evaluation, never pays for it.
|
||||||
|
#[test]
|
||||||
|
fn partial_models_go_only_to_a_watcher_that_wants_them() {
|
||||||
|
let mut doc = crate::Document::new("partial");
|
||||||
|
let block = crate::FeatureKind::Box {
|
||||||
|
corner: crate::point_expr(solidcraft_geom::Vec3::ZERO),
|
||||||
|
length: "2 mm".into(),
|
||||||
|
width: "3 mm".into(),
|
||||||
|
height: "4 mm".into(),
|
||||||
|
operation: crate::Operation::NewBody,
|
||||||
|
};
|
||||||
|
doc.add_feature(block, Some("Block")).unwrap();
|
||||||
|
let mut model = crate::Model::default();
|
||||||
|
model.evaluate(&doc);
|
||||||
|
let state = model.state();
|
||||||
|
let offer = |p: &Arc<Progress>| {
|
||||||
|
watch(p.clone(), || {
|
||||||
|
let e = begin(1);
|
||||||
|
e.offer(1, &doc, &state);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
let p = Arc::new(Progress::default());
|
||||||
|
offer(&p);
|
||||||
|
assert!(p.take_partial().is_none(), "not asked for");
|
||||||
|
p.want_partial();
|
||||||
|
offer(&p);
|
||||||
|
assert!(p.take_partial().is_none(), "nothing in the first interval: a quick open offers none");
|
||||||
|
if let Ok(mut g) = p.partial.lock() {
|
||||||
|
g.1 = web_time::Instant::now().checked_sub(PARTIAL_EVERY * 2);
|
||||||
|
}
|
||||||
|
offer(&p);
|
||||||
|
let partial = p.take_partial().unwrap();
|
||||||
|
assert_eq!(partial.features, 1);
|
||||||
|
assert_eq!(partial.world.bodies.len(), 1);
|
||||||
|
assert!(p.take_partial().is_none(), "taken once");
|
||||||
|
offer(&p);
|
||||||
|
assert!(p.take_partial().is_none(), "not again within the interval");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -82,6 +82,10 @@ struct Running {
|
|||||||
progress: Arc<Progress>,
|
progress: Arc<Progress>,
|
||||||
preview_rx: Option<Receiver<egui::ColorImage>>,
|
preview_rx: Option<Receiver<egui::ColorImage>>,
|
||||||
preview: Option<egui::TextureHandle>,
|
preview: Option<egui::TextureHandle>,
|
||||||
|
/// Pictures of the model as far as it is built (the picture, features built).
|
||||||
|
partial_rx: Option<Receiver<(egui::ColorImage, usize)>>,
|
||||||
|
/// The picture shown is of the first this many features (`None`: the saved preview).
|
||||||
|
built: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Running {
|
impl Running {
|
||||||
@@ -249,6 +253,36 @@ fn start(app: &mut SolveApp) {
|
|||||||
let (command, params) = request.command();
|
let (command, params) = request.command();
|
||||||
let scope = request.scope.clone();
|
let scope = request.scope.clone();
|
||||||
let thumbnail = request.kind == Kind::Open;
|
let thumbnail = request.kind == Kind::Open;
|
||||||
|
// While a design rebuilds, a picture of what is built so far (a large design takes a while).
|
||||||
|
let finished = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
|
let partial_rx = (command == "doc.open" || command == "doc.recover").then(|| {
|
||||||
|
progress.want_partial();
|
||||||
|
let (partial_tx, partial_rx) = std::sync::mpsc::channel();
|
||||||
|
let (watch, finished) = (progress.clone(), finished.clone());
|
||||||
|
let _ = std::thread::Builder::new().name("file-load-picture".into()).spawn(move || {
|
||||||
|
let stop = || finished.load(Ordering::Relaxed) || watch.is_cancelled();
|
||||||
|
while !stop() {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||||
|
let Some(partial) = watch.take_partial() else { continue };
|
||||||
|
let began = std::time::Instant::now();
|
||||||
|
let picture = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| partial_picture(&partial.world, &stop))).ok().flatten();
|
||||||
|
if solidcraft_engine::geom::env::var_os("PROFILE_OPEN").is_some() {
|
||||||
|
eprintln!(
|
||||||
|
"SolidCraft partial picture: features={} bodies={} drawn={} ms={:.1}",
|
||||||
|
partial.features,
|
||||||
|
partial.world.bodies.len(),
|
||||||
|
picture.is_some(),
|
||||||
|
began.elapsed().as_secs_f64() * 1000.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(picture) = picture.filter(|_| !stop()) {
|
||||||
|
let _ = partial_tx.send((picture, partial.features));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
partial_rx
|
||||||
|
});
|
||||||
|
let worker_finished = finished;
|
||||||
let spawned = std::thread::Builder::new().name("file-load".into()).spawn(move || {
|
let spawned = std::thread::Builder::new().name("file-load".into()).spawn(move || {
|
||||||
let preview_began = std::time::Instant::now();
|
let preview_began = std::time::Instant::now();
|
||||||
if command == "doc.open" {
|
if command == "doc.open" {
|
||||||
@@ -270,12 +304,24 @@ fn start(app: &mut SolveApp) {
|
|||||||
compute(scratch, command, ¶ms, scope, thumbnail, &worker_phase, &worker_progress)
|
compute(scratch, command, ¶ms, scope, thumbnail, &worker_phase, &worker_progress)
|
||||||
}))
|
}))
|
||||||
.unwrap_or_else(|_| Err("the file operation failed; the current design is unchanged".into()));
|
.unwrap_or_else(|_| Err("the file operation failed; the current design is unchanged".into()));
|
||||||
|
worker_finished.store(true, Ordering::Relaxed);
|
||||||
let _ = tx.send(result);
|
let _ = tx.send(result);
|
||||||
});
|
});
|
||||||
match spawned {
|
match spawned {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
app.file_load.running =
|
app.file_load.running = Some(Running {
|
||||||
Some(Running { request, base, base_revision, before, rx, phase, progress, preview_rx: Some(preview_rx), preview: None })
|
request,
|
||||||
|
base,
|
||||||
|
base_revision,
|
||||||
|
before,
|
||||||
|
rx,
|
||||||
|
phase,
|
||||||
|
progress,
|
||||||
|
preview_rx: Some(preview_rx),
|
||||||
|
preview: None,
|
||||||
|
partial_rx,
|
||||||
|
built: None,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let e = format!("could not start file operation: {e}");
|
let e = format!("could not start file operation: {e}");
|
||||||
@@ -285,6 +331,44 @@ fn start(app: &mut SolveApp) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A small picture of the bodies built so far (their display meshes, made here and kept for
|
||||||
|
/// the finished model), or `None` when there is nothing to show yet, too much to draw, or the
|
||||||
|
/// load ended meanwhile (`stop`).
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn partial_picture(world: &solidcraft_engine::doc::ModelState, stop: &dyn Fn() -> bool) -> Option<egui::ColorImage> {
|
||||||
|
use solidcraft_engine::{
|
||||||
|
geom::Aabb3,
|
||||||
|
render::{Camera, Rgb, Scene, SceneMesh, StandardView},
|
||||||
|
};
|
||||||
|
let mut scene = Scene::default();
|
||||||
|
let mut bounds = Aabb3::EMPTY;
|
||||||
|
let mut triangles = 0usize;
|
||||||
|
for body in &world.bodies {
|
||||||
|
if stop() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mesh = body.mesh();
|
||||||
|
triangles += mesh.triangles.len();
|
||||||
|
if triangles > 4_000_000 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
bounds = bounds.union(&mesh.bounds());
|
||||||
|
scene.meshes.push(SceneMesh { mesh, color: Rgb(176, 186, 199), face_colors: Vec::new() });
|
||||||
|
}
|
||||||
|
if bounds.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut camera = Camera::default();
|
||||||
|
camera.set_view(StandardView::Iso);
|
||||||
|
camera.fit(&bounds);
|
||||||
|
scene.radius = bounds.diagonal();
|
||||||
|
if stop() || !preview_work_fits(&scene, &camera) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let canvas = solidcraft_engine::render::render(&scene, &camera, 320, 200);
|
||||||
|
Some(egui::ColorImage::from_rgba_unmultiplied([320, 200], &canvas.rgba))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn saved_picture(preview: solidcraft_engine::io::DesignPreview) -> Option<egui::ColorImage> {
|
fn saved_picture(preview: solidcraft_engine::io::DesignPreview) -> Option<egui::ColorImage> {
|
||||||
use solidcraft_engine::{
|
use solidcraft_engine::{
|
||||||
@@ -429,6 +513,14 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
|
|||||||
if !running.cancelled() && running.matches(&app.session) {
|
if !running.cancelled() && running.matches(&app.session) {
|
||||||
if let Some(image) = running.preview_rx.as_ref().and_then(|rx| rx.try_recv().ok()) {
|
if let Some(image) = running.preview_rx.as_ref().and_then(|rx| rx.try_recv().ok()) {
|
||||||
running.preview = Some(ctx.load_texture("opening-preview", image, egui::TextureOptions::LINEAR));
|
running.preview = Some(ctx.load_texture("opening-preview", image, egui::TextureOptions::LINEAR));
|
||||||
|
running.built = None;
|
||||||
|
}
|
||||||
|
// The model as far as it is built, unless the file's saved picture (all of it) is up.
|
||||||
|
if let Some((image, built)) = running.partial_rx.as_ref().and_then(|rx| rx.try_iter().last())
|
||||||
|
&& (running.preview.is_none() || running.built.is_some())
|
||||||
|
{
|
||||||
|
running.preview = Some(ctx.load_texture("opening-preview", image, egui::TextureOptions::LINEAR));
|
||||||
|
running.built = Some(built);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
running.preview = None;
|
running.preview = None;
|
||||||
@@ -572,7 +664,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
|||||||
if let Some(preview) = r.preview.as_ref().filter(|_| !cancelled) {
|
if let Some(preview) = r.preview.as_ref().filter(|_| !cancelled) {
|
||||||
let w = ui.available_width().min(320.0);
|
let w = ui.available_width().min(320.0);
|
||||||
ui.image((preview.id(), egui::vec2(w, w * 200.0 / 320.0)));
|
ui.image((preview.id(), egui::vec2(w, w * 200.0 / 320.0)));
|
||||||
ui.label(egui::RichText::new(crate::i18n::tr_label("Saved preview. Geometry is being checked before editing.")).small().weak());
|
let note = match r.built {
|
||||||
|
Some(built) => format!("Built so far: {built} of {total} features."),
|
||||||
|
None => crate::i18n::tr_label("Saved preview. Geometry is being checked before editing.").to_string(),
|
||||||
|
};
|
||||||
|
ui.label(egui::RichText::new(note).small().weak());
|
||||||
}
|
}
|
||||||
ui.add_space(4.0);
|
ui.add_space(4.0);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
@@ -636,6 +732,8 @@ mod tests {
|
|||||||
progress: Arc::new(Progress::default()),
|
progress: Arc::new(Progress::default()),
|
||||||
preview_rx: Some(preview_rx),
|
preview_rx: Some(preview_rx),
|
||||||
preview: None,
|
preview: None,
|
||||||
|
partial_rx: None,
|
||||||
|
built: None,
|
||||||
});
|
});
|
||||||
if change_document {
|
if change_document {
|
||||||
app.session.execute("file.new", &serde_json::json!({"name":"Newer design"})).unwrap();
|
app.session.execute("file.new", &serde_json::json!({"name":"Newer design"})).unwrap();
|
||||||
@@ -698,6 +796,8 @@ mod tests {
|
|||||||
progress: Arc::new(Progress::default()),
|
progress: Arc::new(Progress::default()),
|
||||||
preview_rx: None,
|
preview_rx: None,
|
||||||
preview: None,
|
preview: None,
|
||||||
|
partial_rx: None,
|
||||||
|
built: None,
|
||||||
});
|
});
|
||||||
tick(&mut app, &egui::Context::default());
|
tick(&mut app, &egui::Context::default());
|
||||||
assert!(Arc::ptr_eq(&app.session.doc, &old));
|
assert!(Arc::ptr_eq(&app.session.doc, &old));
|
||||||
@@ -721,6 +821,8 @@ mod tests {
|
|||||||
progress,
|
progress,
|
||||||
preview_rx: None,
|
preview_rx: None,
|
||||||
preview: None,
|
preview: None,
|
||||||
|
partial_rx: None,
|
||||||
|
built: None,
|
||||||
});
|
});
|
||||||
assert!(!app.file_load.busy());
|
assert!(!app.file_load.busy());
|
||||||
assert!(app.file_load.running.is_some());
|
assert!(app.file_load.running.is_some());
|
||||||
|
|||||||
Reference in new issue
Block a user