Add design containers with deduplicated blobs and JSON compatibility (#327)
* Add fast design containers with lossless deduplicated payloads * Keep display caching opt-in to avoid default save overhead
This commit is contained in:
1 parent
8e0c3d2794
commit
006a23a767
30 files changed
+1921
-71
No files matched your search
@@ -28,10 +28,19 @@ type Job = Result<Made, String>;
|
||||
struct Running {
|
||||
request: Request,
|
||||
base: Arc<Document>,
|
||||
base_revision: u64,
|
||||
before: Vec<String>,
|
||||
rx: Receiver<Job>,
|
||||
phase: Arc<AtomicU8>,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -82,24 +91,134 @@ fn start(app: &mut SolveApp) {
|
||||
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 cancelled = Arc::new(AtomicBool::new(false));
|
||||
let worker_cancelled = cancelled.clone();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let (preview_tx, preview_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 preview_began = std::time::Instant::now();
|
||||
if !insert {
|
||||
let cancelled = worker_cancelled.clone();
|
||||
scratch.open_preview = Some(Box::new(move |preview| {
|
||||
if !cancelled.load(Ordering::Relaxed) {
|
||||
// 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(|_| !cancelled.load(Ordering::Relaxed)) {
|
||||
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, &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 }),
|
||||
Ok(_) => {
|
||||
app.file_load.running =
|
||||
Some(Running { request, base, base_revision, before, rx, phase, cancelled, preview_rx: Some(preview_rx), preview: None })
|
||||
}
|
||||
Err(e) => app.set_status(format!("could not start file operation: {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, path: &str, insert: bool, phase: &AtomicU8, cancelled: &AtomicBool) -> Job {
|
||||
let profile = solidcraft_engine::geom::env::var_os("PROFILE_OPEN").is_some();
|
||||
@@ -144,6 +263,15 @@ fn compute(mut session: Session, path: &str, insert: bool, phase: &AtomicU8, can
|
||||
}
|
||||
|
||||
pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if let Some(running) = &mut app.file_load.running {
|
||||
if !running.cancelled.load(Ordering::Relaxed) && 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,
|
||||
@@ -154,7 +282,7 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
{
|
||||
match job {
|
||||
_ if running.cancelled.load(Ordering::Relaxed) => {}
|
||||
Ok(made) if !running.request.insert || Arc::ptr_eq(&app.session.doc, &running.base) => {
|
||||
Ok(made) if running.matches(&app.session) => {
|
||||
if running.request.insert {
|
||||
app.session.accept_computed(made.session);
|
||||
app.big_operation = true;
|
||||
@@ -176,7 +304,7 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
false,
|
||||
);
|
||||
}
|
||||
Ok(_) => app.set_status("the active design changed while importing; the import was discarded", true),
|
||||
Ok(_) => app.set_status("the active design changed while loading; the file operation was discarded", true),
|
||||
Err(e) => app.set_status(e, true),
|
||||
}
|
||||
}
|
||||
@@ -205,6 +333,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
"Opening design"
|
||||
});
|
||||
ui.label(name.as_ref());
|
||||
if let Some(preview) = r.preview.as_ref().filter(|_| !cancelled) {
|
||||
ui.image((preview.id(), egui::vec2(320.0, 200.0)));
|
||||
ui.label("Saved preview. Geometry is being checked before editing.");
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label(if cancelled {
|
||||
@@ -233,6 +365,71 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
#[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 { path: "old.solidcraft".into(), insert },
|
||||
base,
|
||||
base_revision: app.session.revision,
|
||||
before: Vec::new(),
|
||||
rx,
|
||||
phase: Arc::new(AtomicU8::new(0)),
|
||||
cancelled: Arc::new(AtomicBool::new(false)),
|
||||
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());
|
||||
@@ -242,10 +439,13 @@ mod tests {
|
||||
app.file_load.running = Some(Running {
|
||||
request: Request { path: "bad.step".into(), insert: true },
|
||||
base: old.clone(),
|
||||
base_revision: app.session.revision,
|
||||
before: Vec::new(),
|
||||
rx,
|
||||
phase: Arc::new(AtomicU8::new(0)),
|
||||
cancelled: Arc::new(AtomicBool::new(false)),
|
||||
preview_rx: None,
|
||||
preview: None,
|
||||
});
|
||||
tick(&mut app, &egui::Context::default());
|
||||
assert!(Arc::ptr_eq(&app.session.doc, &old));
|
||||
@@ -261,10 +461,13 @@ mod tests {
|
||||
app.file_load.running = Some(Running {
|
||||
request: Request { path: "cancelled.step".into(), insert: true },
|
||||
base: old.clone(),
|
||||
base_revision: app.session.revision,
|
||||
before: Vec::new(),
|
||||
rx,
|
||||
phase: Arc::new(AtomicU8::new(0)),
|
||||
cancelled,
|
||||
preview_rx: None,
|
||||
preview: None,
|
||||
});
|
||||
assert!(!app.file_load.busy());
|
||||
assert!(app.file_load.running.is_some());
|
||||
|
||||
@@ -372,6 +372,10 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
save_as(app);
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Save As JSON…", "") {
|
||||
save_as_with_extension(app, "json");
|
||||
close = true;
|
||||
}
|
||||
ui.separator();
|
||||
if item_if(ui, "Export STL…", "", shown) {
|
||||
export(app, "stl");
|
||||
@@ -389,6 +393,10 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
export(app, "obj");
|
||||
close = true;
|
||||
}
|
||||
if item(ui, "Export Design as JSON…", "") {
|
||||
export(app, "json");
|
||||
close = true;
|
||||
}
|
||||
ui.separator();
|
||||
if item(ui, &format!("{}…", app.tr("ui.preferences")), "") {
|
||||
app.prefs_window.open = true;
|
||||
@@ -430,8 +438,12 @@ pub fn save(app: &mut SolveApp) {
|
||||
}
|
||||
|
||||
fn save_as(app: &mut SolveApp) {
|
||||
let name = format!("{}.{}", app.session.doc.name, solidcraft_engine::io::DESIGN_EXT);
|
||||
if let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&name, &[solidcraft_engine::io::DESIGN_EXT])) {
|
||||
save_as_with_extension(app, solidcraft_engine::io::DESIGN_EXT);
|
||||
}
|
||||
|
||||
fn save_as_with_extension(app: &mut SolveApp, extension: &str) {
|
||||
let name = format!("{}.{extension}", app.session.doc.name);
|
||||
if let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&name, &[solidcraft_engine::io::DESIGN_EXT, "json"])) {
|
||||
let _ = app.run("file.save_as", json!({"path": p}));
|
||||
}
|
||||
}
|
||||
@@ -1045,6 +1057,29 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
mod header_report_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn file_menu_json_save_and_export_use_commands_and_keep_distinct_paths() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("solid.box", json!({"length":2,"width":3,"height":4})).unwrap();
|
||||
let dir = h.dir.clone();
|
||||
h.app.services.pick_save = Some(Box::new(move |name, _extensions| Some(dir.join(name).to_string_lossy().into_owned())));
|
||||
h.frames(3);
|
||||
h.step(&json!({"click":{"handle":"appbar:folder"}})).unwrap();
|
||||
h.step(&json!({"widget":"Save As JSON…"})).unwrap();
|
||||
let saved = h.app.session.path.clone().unwrap();
|
||||
let bytes = std::fs::read(&saved).unwrap();
|
||||
assert!(!solidcraft_engine::io::is_design_container(&bytes));
|
||||
assert_eq!(solidcraft_engine::io::read_design(&bytes).unwrap(), *h.app.session.doc);
|
||||
h.app.session.doc_mut().name = "Exported copy".into();
|
||||
h.frames(3);
|
||||
h.step(&json!({"click":{"handle":"appbar:folder"}})).unwrap();
|
||||
h.step(&json!({"widget":"Export Design as JSON…"})).unwrap();
|
||||
assert_eq!(h.app.session.path.as_deref(), Some(saved.as_str()));
|
||||
assert!(h.dir.join("Exported copy.json").is_file());
|
||||
h.app.run("file.save", json!({})).unwrap();
|
||||
assert!(!solidcraft_engine::io::is_design_container(&std::fs::read(saved).unwrap()), "subsequent Save keeps a .json choice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panels_fit_collapse_then_move_to_more_and_none_is_dropped() {
|
||||
// A panel with 14 buttons and three default ones.
|
||||
|
||||
Reference in new issue
Block a user