Files
solidcraft-upstream/crates/ui-egui/src/file_load.rs
T
bherbruckandClaude Opus 5.5 e30f3c9ebe fix: Keep the loading card's Cancel button still; deterministic Cancel test (#686)
The loading card was centred on its height, so Cancel moved when the progress bar or the saved preview appeared, and a click could miss it. That's what made the Cancel test flaky on slow runners. The card is now anchored at its top, Cancel sits in the heading row and the progress bar is always shown. Progress::hold_at lets the test hold a load at a feature boundary until it's cancelled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 09:05:13 -04:00

689 lines
29 KiB
Rust

//! 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.
//!
//! While one runs, the window keeps drawing frames: the title bar still moves, resizes,
//! minimizes and maximizes the window, and a light overlay over the design shows the file's
//! progress with Cancel. The overlay takes the pointer, shortcuts and commands wait, and
//! control-channel commands that change the design are held until the file is in. A control
//! (MCP) `doc.open` or import is answered when its file finishes loading.
use std::collections::VecDeque;
use std::sync::{
Arc,
atomic::{AtomicU8, Ordering},
mpsc::{Receiver, Sender, TryRecvError},
};
use serde_json::{Value, json};
use solidcraft_engine::{Session, doc::Document, doc::progress::Progress};
use crate::SolveApp;
use crate::control::ControlResponse;
/// What a file operation does with its file.
#[derive(Clone, Debug, PartialEq)]
pub enum Kind {
/// Open the design (or a STEP/mesh file as a new design) in a tab of its own.
Open,
/// Insert a STEP or mesh file's bodies into the active design.
Insert,
/// Open a design an earlier run autosaved before it closed.
Recover { id: String, dir: Option<String> },
}
pub struct Request {
/// The file, or for a recovery the design's name.
pub path: String,
pub kind: Kind,
/// A control-channel caller waiting for the command's reply.
pub reply: Option<Sender<ControlResponse>>,
}
impl Request {
pub fn new(path: &str, kind: Kind) -> Self {
Request { path: path.into(), kind, reply: None }
}
fn insert(&self) -> bool {
self.kind == Kind::Insert
}
fn command(&self) -> (&'static str, Value) {
match &self.kind {
Kind::Open => ("doc.open", json!({"path": self.path})),
Kind::Insert if solidcraft_engine::io::is_mesh_path(&self.path) => ("file.insert_mesh", json!({"path": self.path})),
Kind::Insert => ("file.insert_step", json!({"path": self.path})),
Kind::Recover { id, dir } => ("doc.recover", json!({"id": id, "dir": dir})),
}
}
fn answer(&self, r: Result<Value, String>) {
if let Some(reply) = &self.reply {
let _ = reply.send(match r {
Ok(v) => json!({"ok": true, "result": v}),
Err(e) => json!({"ok": false, "error": e}),
});
}
}
}
struct Made {
session: Session,
result: Value,
image: Option<egui::ColorImage>,
}
type Job = Result<Made, String>;
struct Running {
request: Request,
base: Arc<Document>,
base_revision: u64,
before: Vec<String>,
rx: Receiver<Job>,
phase: Arc<AtomicU8>,
progress: Arc<Progress>,
preview_rx: Option<Receiver<egui::ColorImage>>,
preview: Option<egui::TextureHandle>,
}
impl Running {
fn matches(&self, session: &Session) -> bool {
Arc::ptr_eq(&session.doc, &self.base) && session.revision == self.base_revision
}
fn cancelled(&self) -> bool {
self.progress.is_cancelled()
}
}
#[derive(Default)]
pub struct FileLoad {
queued: VecDeque<Request>,
running: Option<Running>,
/// Tests: the next load waits before this feature until cancelled.
#[cfg(test)]
pub hold_at: Option<usize>,
}
impl FileLoad {
/// A file operation is under way: the design waits for it.
pub fn busy(&self) -> bool {
self.running.as_ref().is_some_and(|r| !r.cancelled()) || !self.queued.is_empty()
}
/// The running load's progress: (features rebuilt, timeline length).
pub fn features(&self) -> Option<(usize, usize)> {
self.running.as_ref().map(|r| r.progress.features())
}
/// Nothing running or queued (a cancelled job may still be finishing in [`Self::busy`]'s eyes).
pub fn idle(&self) -> bool {
self.running.is_none() && self.queued.is_empty()
}
/// Stop the running operation at its next feature and drop the queued ones.
pub fn cancel(&mut self) {
if let Some(r) = &self.running {
r.progress.cancel();
}
for q in self.queued.drain(..) {
q.answer(Err("file operation cancelled".into()));
}
}
}
pub fn begin(app: &mut SolveApp, path: &str, insert: bool) {
let _ = begin_request(app, Request::new(path, if insert { Kind::Insert } else { Kind::Open }));
}
/// Queue a file operation; on the web (no threads) it runs now.
pub fn begin_request(app: &mut SolveApp, request: Request) -> Result<(), String> {
#[cfg(target_arch = "wasm32")]
{
let r = match &request.kind {
Kind::Insert => {
app.insert_path(&request.path);
Ok(Value::Null)
}
Kind::Open => {
app.open_path(&request.path);
Ok(Value::Null)
}
Kind::Recover { .. } => {
let (command, params) = request.command();
let r = app.run(command, params);
if r.is_ok() {
recovered(app, &request);
}
r
}
};
request.answer(r);
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
{
if app.file_load.queued.len() >= crate::documents::MAX_OPEN {
let e = "too many queued files".to_string();
app.set_status(e.clone(), true);
request.answer(Err(e.clone()));
return Err(e);
}
app.file_load.queued.push_back(request);
start(app);
Ok(())
}
}
/// A control-channel (MCP) `doc.open` or STEP/mesh import runs like File > Open: off the window
/// thread, answered when the file is in. Other requests come back to be handled now.
pub fn control(app: &mut SolveApp, req: crate::control::ControlRequest) -> Option<crate::control::ControlRequest> {
if cfg!(target_arch = "wasm32") || !matches!(req.method.as_str(), "engine.execute" | "command") {
return Some(req);
}
let command = req.params.get("command").or(req.params.get("id")).and_then(Value::as_str);
let kind = match command {
Some("doc.open") => Kind::Open,
Some("file.insert_step" | "file.insert_mesh") => Kind::Insert,
_ => return Some(req),
};
let Some(path) = req.params.get("params").and_then(|p| p.get("path")).and_then(Value::as_str) else { return Some(req) };
let _ = begin_request(app, Request { path: path.into(), kind, reply: Some(req.reply.clone()) });
None
}
/// A recovered design is ours now: autosave it under this app's entry before the old one goes.
fn recovered(app: &mut SolveApp, request: &Request) {
let Kind::Recover { id, dir } = &request.kind else { return };
let kept = app.autosave.as_mut().map(|a| a.save(&app.session));
if let Some(Ok(_)) = kept {
let _ = app.session.execute("doc.recovery_discard", &json!({"id": id, "dir": dir}));
}
app.set_status(format!("recovered {}: save it to keep it", request.path), false);
}
#[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) {
let e = "too many open designs; close a tab before opening another";
app.set_status(e, true);
request.answer(Err(e.into()));
return;
}
let insert = request.insert();
let before = if insert { app.session.world_state().bodies.iter().map(|b| b.name.clone()).collect() } else { Vec::new() };
let mut scratch = if insert { app.session.scratch() } else { Session::default() };
if insert {
scratch.selection = app.session.selection.clone();
}
let base = app.session.doc.clone();
let base_revision = app.session.revision;
let phase = Arc::new(AtomicU8::new(0));
let worker_phase = phase.clone();
let progress = Arc::new(Progress::default());
#[cfg(test)]
if let Some(i) = app.file_load.hold_at.take() {
progress.hold_at(i);
}
let worker_progress = progress.clone();
let (tx, rx) = std::sync::mpsc::channel();
let (preview_tx, preview_rx) = std::sync::mpsc::channel();
let (command, params) = request.command();
let thumbnail = request.kind == Kind::Open;
let spawned = std::thread::Builder::new().name("file-load".into()).spawn(move || {
let preview_began = std::time::Instant::now();
if command == "doc.open" {
let progress = worker_progress.clone();
scratch.open_preview = Some(Box::new(move |preview| {
if !progress.is_cancelled() {
// Render on the file worker, never the window thread.
let picture = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| saved_picture(preview))).ok().flatten();
if let Some(picture) = picture.filter(|_| !progress.is_cancelled()) {
let _ = preview_tx.send(picture);
if solidcraft_engine::geom::env::var_os("PROFILE_OPEN").is_some() {
eprintln!("SolidCraft saved preview: ready_ms={:.3}", preview_began.elapsed().as_secs_f64() * 1000.0);
}
}
}
}));
}
let result =
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| compute(scratch, command, &params, thumbnail, &worker_phase, &worker_progress)))
.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, base_revision, before, rx, phase, progress, preview_rx: Some(preview_rx), preview: None })
}
Err(e) => {
let e = format!("could not start file operation: {e}");
request.answer(Err(e.clone()));
app.set_status(e, true);
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn saved_picture(preview: solidcraft_engine::io::DesignPreview) -> Option<egui::ColorImage> {
use solidcraft_engine::{
geom::{Aabb3, Mesh, Vec3},
render::{Camera, Rgb, Scene, SceneMesh, StandardView},
};
let mut scene = Scene::default();
let mut bounds = Aabb3::EMPTY;
for cached in preview.meshes {
let mut mesh = Mesh {
positions: cached.positions.as_chunks::<3>().0.iter().map(|p| Vec3::new(p[0], p[1], p[2])).collect(),
triangles: cached.triangles.as_chunks::<3>().0.to_vec(),
..Default::default()
};
mesh.normals.resize(mesh.positions.len(), Vec3::ZERO);
for triangle in &mesh.triangles {
let a = *mesh.positions.get(*triangle.first()? as usize)?;
let b = *mesh.positions.get(*triangle.get(1)? as usize)?;
let c = *mesh.positions.get(*triangle.get(2)? as usize)?;
let normal = (b - a).cross(c - a).normalized().unwrap_or(Vec3::Z);
for index in triangle {
if let Some(n) = mesh.normals.get_mut(*index as usize) {
*n += normal;
}
}
}
for normal in &mut mesh.normals {
*normal = normal.normalized().unwrap_or(Vec3::Z);
}
bounds = bounds.union(&mesh.bounds());
let [r, g, b] = cached.color.unwrap_or([0.69, 0.73, 0.78]).map(|c| (c * 255.0).round() as u8);
scene.meshes.push(SceneMesh { mesh: Arc::new(mesh), color: Rgb(r, g, b), 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 !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))
}
/// Bound raster work as well as file size: repeated full-screen triangles must
/// not monopolize the file worker. Match the rasterizer's 2x sampling rectangles.
#[cfg(not(target_arch = "wasm32"))]
fn preview_work_fits(scene: &solidcraft_engine::render::Scene, camera: &solidcraft_engine::render::Camera) -> bool {
let mut pixels = 0.0;
let projection = camera.view_proj(640.0 / 400.0, scene.radius.max(1.0));
for body in &scene.meshes {
let points: Vec<_> = body
.mesh
.positions
.iter()
.map(|p| {
let q = projection.apply(*p);
(q[3] > 1e-9).then(|| ((q[0] / q[3] + 1.0) * 320.0, (1.0 - q[1] / q[3]) * 200.0, q[2] / q[3]))
})
.collect();
for triangle in &body.mesh.triangles {
let mut lo = [f64::INFINITY; 2];
let mut hi = [f64::NEG_INFINITY; 2];
let mut visible = true;
for index in triangle {
if let Some(Some((x, y, _))) = points.get(*index as usize) {
lo = [lo[0].min(*x), lo[1].min(*y)];
hi = [hi[0].max(*x), hi[1].max(*y)];
} else {
visible = false;
break;
}
}
if visible {
let width = (hi[0].ceil().min(639.0) - lo[0].floor().max(0.0) + 1.0).max(0.0);
let height = (hi[1].ceil().min(399.0) - lo[1].floor().max(0.0) + 1.0).max(0.0);
pixels += width * height;
if !pixels.is_finite() || pixels > 16_000_000.0 {
return false;
}
}
}
}
true
}
#[cfg(not(target_arch = "wasm32"))]
fn compute(mut session: Session, command: &str, params: &Value, thumbnail: bool, phase: &AtomicU8, progress: &Arc<Progress>) -> Job {
let profile = solidcraft_engine::geom::env::var_os("PROFILE_OPEN").is_some();
let began = std::time::Instant::now();
let cancelled = || Err("file operation cancelled".to_string());
if progress.is_cancelled() {
return cancelled();
}
// The timeline's rebuild reports each feature and stops at the next one after Cancel.
let result = solidcraft_engine::doc::progress::watch(progress.clone(), || session.execute(command, params)).map_err(|e| e.to_string())?;
if progress.is_cancelled() {
return cancelled();
}
let geometry_ms = began.elapsed().as_secs_f64() * 1000.0;
let view_began = std::time::Instant::now();
phase.store(1, Ordering::Relaxed);
let world = session.world_state();
let placement_ms = view_began.elapsed().as_secs_f64() * 1000.0;
let mesh_began = std::time::Instant::now();
// Fit, picking and the first viewport frame must not trigger cold tessellation on the UI.
for b in &world.bodies {
if progress.is_cancelled() {
return cancelled();
}
let _ = b.mesh();
}
let tessellation_ms = mesh_began.elapsed().as_secs_f64() * 1000.0;
phase.store(2, Ordering::Relaxed);
let view_ms = view_began.elapsed().as_secs_f64() * 1000.0;
let thumbnail_began = std::time::Instant::now();
let image = if thumbnail { crate::home::remember(&session) } else { None };
if profile {
eprintln!(
"SolidCraft open phases: geometry_ms={geometry_ms:.3} placement_ms={placement_ms:.3} tessellation_ms={tessellation_ms:.3} display_warmup_ms={view_ms:.3} thumbnail_ms={:.3} total_ms={:.3}",
thumbnail_began.elapsed().as_secs_f64() * 1000.0,
began.elapsed().as_secs_f64() * 1000.0
);
}
Ok(Made { session, result, image })
}
pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
if let Some(running) = &mut app.file_load.running {
if !running.cancelled() && running.matches(&app.session) {
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));
}
} else {
running.preview = None;
}
}
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()
{
let request = &running.request;
match job {
_ if running.cancelled() => request.answer(Err("file operation cancelled".into())),
Ok(made) if running.matches(&app.session) => {
if request.insert() {
app.session.accept_computed(made.session);
app.big_operation = true;
} else {
crate::documents::adopt(app, made.session);
if request.kind == Kind::Open {
app.home.add_recent(&request.path);
crate::home::install(&mut app.home, ctx, &request.path, made.image);
}
}
app.home.open = false;
app.fit_view();
if request.kind == Kind::Open || request.insert() {
crate::stand_up::offer(app, &request.path, &running.before);
}
let warnings = made.result.get("warnings").and_then(Value::as_array);
if let Kind::Recover { .. } = request.kind {
recovered(app, request);
} else {
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 request.insert() { "Insert" } else { "Open" })
},
false,
);
}
request.answer(Ok(made.result));
}
Ok(_) => {
let e = "the active design changed while loading; the file operation was discarded";
request.answer(Err(e.into()));
app.set_status(e, true);
}
Err(e) => {
request.answer(Err(e.clone()));
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));
}
}
/// The light overlay over everything under the title bar while a file loads. The title bar,
/// its window buttons and the window's resize edges stay live; the design underneath takes no
/// pointer input until the file is in.
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let Some(r) = &app.file_load.running else { return };
let cancelled = r.cancelled();
if cancelled && app.file_load.queued.is_empty() {
return;
}
let name = match &r.request.kind {
Kind::Recover { .. } => r.request.path.clone(),
_ => std::path::Path::new(&r.request.path).file_name().map(|p| p.to_string_lossy().to_string()).unwrap_or_default(),
};
let phase = r.phase.load(Ordering::Relaxed);
let (done, total) = r.progress.features();
let screen = ctx.content_rect();
// Leave the title bar to the window. The resize edges are a foreground layer above this.
let cover = egui::Rect::from_min_max(egui::pos2(screen.left(), screen.top() + crate::toolbar::APP_BAR_HEIGHT), screen.max);
if cover.width() <= 0.0 || cover.height() <= 0.0 {
return;
}
let centre = app.viewport.rect.filter(|v| cover.contains_rect(*v)).unwrap_or(cover).center();
// Typing goes nowhere until the design is in.
ctx.memory_mut(|m| {
if let Some(id) = m.focused() {
m.surrender_focus(id);
}
});
let t = crate::theme::Tokens::get();
let mut cancel = ctx.input(|i| i.key_pressed(egui::Key::Escape));
let id = egui::Id::new("file_loading");
let card_id = id.with("card");
// Above the dialogs over the design; menus, tooltips and the resize edges stay above it.
ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id));
ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, card_id));
egui::Area::new(id).order(egui::Order::Middle).fixed_pos(cover.min).interactable(true).show(ctx, |ui| {
ui.set_min_size(cover.size());
ui.interact(cover, egui::Id::new("file_loading_block"), egui::Sense::click_and_drag());
ui.painter().rect_filled(cover, 0.0, egui::Color32::from_black_alpha(70));
});
let width = 340.0f32.min(cover.width() - 16.0).max(120.0);
// Anchored at its top with Cancel in the heading row: the card grows downwards (the saved
// preview, the progress bar) and Cancel never moves under the pointer.
let top = egui::pos2(centre.x, (centre.y - 120.0).max(cover.top() + 8.0));
egui::Area::new(card_id).order(egui::Order::Middle).pivot(egui::Align2::CENTER_TOP).fixed_pos(top).show(ctx, |child| {
egui::Frame::popup(child.style()).fill(t.panel).show(child, |ui| {
ui.set_width(width - 16.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(if cancelled {
"Waiting for file operation"
} else {
match r.request.kind {
Kind::Insert => "Inserting geometry",
Kind::Open => "Opening design",
Kind::Recover { .. } => "Recovering design",
}
})
.strong()
.size(14.0),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let b = ui.button("Cancel");
crate::scenario::publish_handle("file_loading:cancel", b.rect.center());
cancel |= b.clicked();
});
});
ui.label(egui::RichText::new(format!("{name}…")).weak());
if let Some(preview) = r.preview.as_ref().filter(|_| !cancelled) {
let w = ui.available_width().min(320.0);
ui.image((preview.id(), egui::vec2(w, w * 200.0 / 320.0)));
ui.label(egui::RichText::new("Saved preview. Geometry is being checked before editing.").small().weak());
}
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.spinner();
ui.label(if cancelled {
"Waiting for the previous operation to stop...".to_string()
} else {
match phase {
1 => "Preparing the view...".into(),
2 => "Preparing the thumbnail...".into(),
_ if total > 0 => format!("Rebuilding feature {} of {total}", (done + 1).min(total)),
_ => "Reading the file...".into(),
}
});
});
// Always there, so the card keeps its height as the phases change.
let fraction = if cancelled || total == 0 {
0.0
} else if phase == 0 {
done as f32 / total as f32
} else {
1.0
};
ui.add(egui::ProgressBar::new(fraction).desired_height(6.0));
});
});
if cancel && !cancelled {
// Keep the running job until it exits: cancelling must not allow another large
// import to overlap it in memory. The rebuild stops at its next feature and the
// current design stays as it was.
app.file_load.cancel();
app.set_status("file operation cancelled", false);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn opening_result_and_preview_cannot_overwrite_a_newer_design() {
for (insert, change_document) in [(false, true), (true, true), (false, false)] {
let mut app = SolveApp::new(Session::default(), Default::default());
let base = app.session.doc.clone();
let (tx, rx) = std::sync::mpsc::channel();
let (preview_tx, preview_rx) = std::sync::mpsc::channel();
preview_tx.send(egui::ColorImage::new([2, 2], vec![egui::Color32::RED; 4])).unwrap();
app.file_load.running = Some(Running {
request: Request::new("old.solidcraft", if insert { Kind::Insert } else { Kind::Open }),
base,
base_revision: app.session.revision,
before: Vec::new(),
rx,
phase: Arc::new(AtomicU8::new(0)),
progress: Arc::new(Progress::default()),
preview_rx: Some(preview_rx),
preview: None,
});
if change_document {
app.session.execute("file.new", &serde_json::json!({"name":"Newer design"})).unwrap();
} else {
// An edit and undo can return to the original Arc; the generation still changed.
app.session.revision += 1;
}
let newer = app.session.doc.clone();
tick(&mut app, &egui::Context::default());
assert!(app.file_load.running.as_ref().unwrap().preview.is_none());
tx.send(Ok(Made { session: Session::default(), result: Value::Null, image: None })).unwrap();
tick(&mut app, &egui::Context::default());
assert!(Arc::ptr_eq(&newer, &app.session.doc));
assert!(app.status.as_ref().unwrap().0.contains("discarded"));
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn provisional_picture_renders_without_adopting_cached_meshes() {
let preview = solidcraft_engine::io::DesignPreview {
meshes: vec![solidcraft_engine::doc::MeshData {
name: "Saved triangle".into(),
positions: vec![0.0, 0.0, 0.0, 10.0, 0.0, 0.0, 0.0, 10.0, 0.0],
triangles: vec![0, 1, 2],
color: Some([0.8, 0.2, 0.1]),
}],
};
let picture = saved_picture(preview).unwrap();
assert_eq!(picture.size, [320, 200]);
assert!(picture.pixels.iter().any(|p| u16::from(p.r()) > u16::from(p.g()) * 2));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn repeated_large_preview_triangles_are_a_cache_miss_before_rasterization() {
let preview = solidcraft_engine::io::DesignPreview {
meshes: vec![solidcraft_engine::doc::MeshData {
name: "Hostile overdraw".into(),
positions: vec![0.0, 0.0, 0.0, 10.0, 0.0, 0.0, 0.0, 10.0, 0.0],
triangles: (0..10_000).flat_map(|_| [0, 1, 2]).collect(),
color: None,
}],
};
assert!(saved_picture(preview).is_none());
}
#[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::new("bad.step", Kind::Insert),
base: old.clone(),
base_revision: app.session.revision,
before: Vec::new(),
rx,
phase: Arc::new(AtomicU8::new(0)),
progress: Arc::new(Progress::default()),
preview_rx: None,
preview: None,
});
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 progress = Arc::new(Progress::default());
progress.cancel();
app.file_load.running = Some(Running {
request: Request::new("cancelled.step", Kind::Insert),
base: old.clone(),
base_revision: app.session.revision,
before: Vec::new(),
rx,
phase: Arc::new(AtomicU8::new(0)),
progress,
preview_rx: None,
preview: None,
});
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));
}
}