diff --git a/README.md b/README.md index 194c830..eda68b7 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ https://github.com/twitchyliquid64/liquid-cad/assets/6328589/70ce6e84-25ff-4af1- ### Backlog for Beta * More work on Help page + * Fix Y-mirroring for exported files * Export to STEP * Arc dragging * Broken constraint tools / solver stability (need help!): diff --git a/detailer/src/lib.rs b/detailer/src/lib.rs index 9c990ec..553f3b9 100644 --- a/detailer/src/lib.rs +++ b/detailer/src/lib.rs @@ -65,6 +65,7 @@ impl<'a> Widget<'a> { .constrain(true) .collapsible(false) .title_bar(false) + .default_height(520.0) .anchor(egui::Align2::RIGHT_TOP, egui::Vec2::new(-4., 4.)); window.show(ctx, |ui| { @@ -987,7 +988,8 @@ impl<'a> Widget<'a> { .text("Flatten tolerance").suffix("mm").logarithmic(true)); if let Some(err) = self.drawing.last_solve_error { - ui.add(egui::Label::new(format!("⚠ Solver is inconsistent!! avg err: {:.3}mm", err))); + ui.add(egui::Label::new(egui::RichText::new(format!("⚠ Solver is inconsistent!! avg err: {:.3}mm", err)) + .color(ui.visuals().warn_fg_color))); ui.add_space(5.0); } @@ -1098,33 +1100,37 @@ impl<'a> Widget<'a> { } if ui.add_enabled(self.drawing.groups.len() > 0, egui::Button::new("STL 📥")).clicked() { - if let Ok(solid) = self.drawing.part_extrude(self.state.extrusion_amt) { - use drawing::l::three_d::*; - - export_fn.take().map(|f| f("STL", "stl", solid_to_stl(solid, self.drawing.props.flatten_tolerance))); - } else { - self.toasts.add(egui_toast::Toast { - text: "Export failed!".into(), - kind: egui_toast::ToastKind::Error, - options: egui_toast::ToastOptions::default() - .duration_in_seconds(4.0) - .show_progress(true) - }); + match self.drawing.as_solid(self.state.extrusion_amt) { + Ok(solid) => { + use drawing::l::three_d::*; + export_fn.take().map(|f| f("STL", "stl", solid_to_stl(solid, self.drawing.props.flatten_tolerance))); + }, + Err(err) => { + self.toasts.add(egui_toast::Toast { + text: format!("Export failed!\n\nErr: {:?}", err).into(), + kind: egui_toast::ToastKind::Error, + options: egui_toast::ToastOptions::default() + .duration_in_seconds(4.0) + .show_progress(true) + }); + } } } if ui.add_enabled(self.drawing.groups.len() > 0, egui::Button::new("OBJ 📥")).clicked() { - if let Ok(solid) = self.drawing.part_extrude(self.state.extrusion_amt) { - use drawing::l::three_d::*; - - export_fn.take().map(|f| f("OBJ", "obj", solid_to_obj(solid, self.drawing.props.flatten_tolerance))); - } else { - self.toasts.add(egui_toast::Toast { - text: "Export failed!".into(), - kind: egui_toast::ToastKind::Error, - options: egui_toast::ToastOptions::default() - .duration_in_seconds(4.0) - .show_progress(true) - }); + match self.drawing.as_solid(self.state.extrusion_amt) { + Ok(solid) => { + use drawing::l::three_d::*; + export_fn.take().map(|f| f("OBJ", "obj", solid_to_obj(solid, self.drawing.props.flatten_tolerance))); + }, + Err(err) => { + self.toasts.add(egui_toast::Toast { + text: format!("Export failed!\n\nErr: {:?}", err).into(), + kind: egui_toast::ToastKind::Error, + options: egui_toast::ToastOptions::default() + .duration_in_seconds(4.0) + .show_progress(true) + }); + } } } }); diff --git a/drawing/src/data/group.rs b/drawing/src/data/group.rs index d555424..3ceb5c7 100644 --- a/drawing/src/data/group.rs +++ b/drawing/src/data/group.rs @@ -59,7 +59,7 @@ impl Group { } } - pub fn compute_path(&self, data: &super::Data) -> Result, ()> { + pub fn compute_path(&self, data: &super::Data) -> Vec { // geometry that has been emitted let mut remaining = self.features.clone(); remaining.reverse(); @@ -139,7 +139,7 @@ impl Group { paths.push(current.0); } - Ok(paths) + paths } } diff --git a/drawing/src/data/mod.rs b/drawing/src/data/mod.rs index f2c92b5..438f527 100644 --- a/drawing/src/data/mod.rs +++ b/drawing/src/data/mod.rs @@ -34,6 +34,19 @@ pub enum SelectedElement { Constraint(ConstraintKey), } +#[derive(Clone, Debug, PartialEq)] +pub enum ExportErr { + NoBoundaryGroup, + MultiBoundaryGroup, + IntersectingGroups(usize, usize), +} + +#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize)] +pub enum CADOp { + Extrude(f64), + Hole, +} + #[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize, PartialEq)] pub struct SerializedDrawing { pub features: Vec, @@ -1055,36 +1068,31 @@ impl Data { } }; - let paths: Vec<(GroupType, Result>, ()>)> = self + let paths: Vec<(GroupType, Vec>)> = self .groups .iter() .map(|g| { - let out = if let Ok(paths) = g.compute_path(self) { - let mut out_paths: Vec> = Vec::with_capacity(4); - for path in paths.into_iter() { - let mut points: Vec = Vec::with_capacity(32); - path.flatten(flatten_tolerance, |el| { - use kurbo::PathEl; - match el { - PathEl::MoveTo(p) | PathEl::LineTo(p) => { - if points.len() == 0 || points[points.len() - 1] != p { - points.push(p); - } + let mut out_paths: Vec> = Vec::with_capacity(4); + for path in g.compute_path(self).into_iter() { + let mut points: Vec = Vec::with_capacity(32); + path.flatten(flatten_tolerance, |el| { + use kurbo::PathEl; + match el { + PathEl::MoveTo(p) | PathEl::LineTo(p) => { + if points.len() == 0 || points[points.len() - 1] != p { + points.push(p); } - PathEl::ClosePath => {} - _ => panic!("unexpected element: {:?}", el), } - }); - if points.len() > 0 { - out_paths.push(points); + PathEl::ClosePath => {} + _ => panic!("unexpected element: {:?}", el), } + }); + if points.len() > 0 { + out_paths.push(points); } - Ok(out_paths) - } else { - Err(()) - }; + } - (g.typ, out) + (g.typ, out_paths) }) .collect(); @@ -1094,7 +1102,6 @@ impl Data { .filter(|(gt, _)| gt == &GroupType::Boundary) .map(|(_gt, paths)| paths.iter()) .flatten() - .flatten() { let mut idx: Vec = Vec::with_capacity(path_points.len()); for point in path_points.iter() { @@ -1108,7 +1115,6 @@ impl Data { .filter(|(gt, _)| gt == &GroupType::Interior) .map(|(_gt, paths)| paths.iter()) .flatten() - .flatten() { let mut idx: Vec = Vec::with_capacity(path_points.len()); for point in path_points.iter() { @@ -1120,23 +1126,23 @@ impl Data { Ok((points, indices_outer, indices_inner)) } - pub fn part_paths(&self) -> Result<(kurbo::BezPath, Vec), ()> { + pub fn part_paths(&self) -> Result<(kurbo::BezPath, Vec<(CADOp, kurbo::BezPath)>), ExportErr> { use crate::GroupType; + use kurbo::Shape; let mut outer: Option = None; - let mut cutouts: Vec = Vec::with_capacity(12); + let mut cutouts: Vec<(CADOp, kurbo::BezPath)> = Vec::with_capacity(12); - let paths: Vec<(GroupType, Result, ()>)> = self + let paths: Vec<(&Group, Vec)> = self .groups .iter() - .map(|g| (g.typ, g.compute_path(self))) + .map(|g| (g, g.compute_path(self))) .collect(); // Do boundaries first for p in paths .iter() - .filter(|(gt, _)| gt == &GroupType::Boundary) - .map(|(_gt, paths)| paths.iter()) - .flatten() + .filter(|(g, _)| g.typ == GroupType::Boundary) + .map(|(_g, paths)| paths.iter()) .flatten() { match outer { @@ -1144,30 +1150,75 @@ impl Data { outer = Some(p.clone()); } Some(_) => { - println!("multiple outer geometries!"); - return Err(()); + return Err(ExportErr::MultiBoundaryGroup); } } } // Now interior geometry - for p in paths + for (_g, paths) in paths .into_iter() - .filter(|(gt, _)| gt == &GroupType::Interior) - .map(|(_gt, paths)| paths.into_iter()) - .flatten() - .flatten() + .filter(|(gt, _)| gt.typ == GroupType::Interior) { - cutouts.push(p); + for p in paths.into_iter() { + cutouts.push((CADOp::Hole, p)); + } } if outer.is_none() { - Err(()) - } else { - Ok((outer.unwrap(), cutouts)) + return Err(ExportErr::NoBoundaryGroup); } + + // Check for intersecting cutouts + let mut cutout_bb: Vec<_> = cutouts.iter().map(|(_, p)| p.bounding_box()).collect(); + println!("{:?}", cutout_bb); + for i1 in 0..cutouts.len() { + for i2 in i1..cutouts.len() { + if i1 == i2 { + continue; + } + if !cutout_bb[i1].intersect(cutout_bb[i2]).is_empty() { + // bounding boxes intersect, need to do expensive intersection to see if + // actual intersection. + let (c1, c2) = (&cutouts[i1], &cutouts[i2]); + for seg in c1.1.segments() { + let mut intersects = false; + kurbo::flatten( + seg.path_elements(self.props.flatten_tolerance), + self.props.flatten_tolerance, + |el| { + use kurbo::PathEl; + match el { + PathEl::MoveTo(p) | PathEl::LineTo(p) => { + intersects |= c2.1.contains(p); + } + PathEl::ClosePath => {} + _ => panic!("unexpected element: {:?}", el), + } + }, + ); + + if intersects { + return Err(ExportErr::IntersectingGroups(i1, i2)); + } + } + } + } + } + + // Order outside-in + cutout_bb.sort_by(|a, b| { + if a.contains(b.center()) { + return std::cmp::Ordering::Less; + } else if b.contains(a.center()) { + return std::cmp::Ordering::Greater; + } + ((a.area() * 1000.0) as u64).cmp(&((b.area() * 1000.0) as u64)) + }); + + Ok((outer.unwrap(), cutouts)) } - pub fn part_extrude(&self, height: f64) -> Result { + pub fn as_solid(&self, height: f64) -> Result { let (exterior, cutouts) = self.part_paths()?; Ok(crate::l::three_d::extrude_from_paths( exterior, cutouts, height, @@ -2084,7 +2135,7 @@ mod tests { assert_eq!( data.groups[0].compute_path(&data), - Ok(vec![ + vec![ kurbo::BezPath::from_vec(vec![ kurbo::PathEl::MoveTo(kurbo::Point { x: 0.0, y: 0.0 }), kurbo::PathEl::LineTo(kurbo::Point { x: 5.0, y: 0.0 }), @@ -2097,7 +2148,7 @@ mod tests { kurbo::PathEl::MoveTo(kurbo::Point { x: 0.0, y: 15.0 }), kurbo::PathEl::LineTo(kurbo::Point { x: 15.0, y: 15.0 }), ]), - ]) + ] ); } @@ -2173,7 +2224,7 @@ mod tests { }) .unwrap(); - let flattened = data.groups[0].compute_path(&data).unwrap(); + let flattened = data.groups[0].compute_path(&data); //println!("{:?}", flattened); assert_eq!( @@ -2368,4 +2419,115 @@ mod tests { assert_eq!(idx_outer, vec![vec![0, 1, 2, 3, 0]]); assert_eq!(idx_inner, Vec::>::new()); } + + #[test] + fn as_solid_error_results() { + let features = vec![ + SerializedFeature { + kind: "pt".to_string(), + using_idx: vec![], + x: 0.0, + y: 0.0, + ..SerializedFeature::default() + }, + SerializedFeature { + kind: "pt".to_string(), + using_idx: vec![], + x: 25.0, + y: 0.0, + ..SerializedFeature::default() + }, + SerializedFeature { + kind: "circle".to_string(), + using_idx: vec![0], + r: 50.0, + ..SerializedFeature::default() + }, + SerializedFeature { + kind: "circle".to_string(), + using_idx: vec![1], + r: 50.0, + ..SerializedFeature::default() + }, + SerializedFeature { + kind: "circle".to_string(), + using_idx: vec![0], + r: 26.0, + ..SerializedFeature::default() + }, + ]; + + { + let mut data = Data::default(); + data.load(SerializedDrawing { + features: features.clone(), + groups: vec![crate::SerializedGroup { + typ: crate::GroupType::Interior, + name: "Not boundary".into(), + features_idx: vec![2], + ..crate::SerializedGroup::default() + }], + ..SerializedDrawing::default() + }) + .unwrap(); + + assert_eq!(data.as_solid(3.0), Err(ExportErr::NoBoundaryGroup)); + } + + { + let mut data = Data::default(); + data.load(SerializedDrawing { + features: features.clone(), + groups: vec![ + crate::SerializedGroup { + typ: crate::GroupType::Boundary, + name: "Boundary".into(), + features_idx: vec![2], + ..crate::SerializedGroup::default() + }, + crate::SerializedGroup { + typ: crate::GroupType::Boundary, + name: "Boundary 2".into(), + features_idx: vec![3], + ..crate::SerializedGroup::default() + }, + ], + ..SerializedDrawing::default() + }) + .unwrap(); + + assert_eq!(data.as_solid(3.0), Err(ExportErr::MultiBoundaryGroup)); + } + + { + let mut data = Data::default(); + data.load(SerializedDrawing { + features, + groups: vec![ + crate::SerializedGroup { + typ: crate::GroupType::Boundary, + name: "Boundary".into(), + features_idx: vec![2], + ..crate::SerializedGroup::default() + }, + crate::SerializedGroup { + typ: crate::GroupType::Interior, + name: "cutout 1".into(), + features_idx: vec![3], + ..crate::SerializedGroup::default() + }, + crate::SerializedGroup { + typ: crate::GroupType::Interior, + name: "cutout 2".into(), + features_idx: vec![4], + ..crate::SerializedGroup::default() + }, + ], + ..SerializedDrawing::default() + }) + .unwrap(); + + assert_eq!(data.as_solid(3.0), Err(ExportErr::IntersectingGroups(0, 1))); + } + } } diff --git a/drawing/src/l/three_d.rs b/drawing/src/l/three_d.rs index f46c16d..74f550a 100644 --- a/drawing/src/l/three_d.rs +++ b/drawing/src/l/three_d.rs @@ -1,3 +1,4 @@ +use crate::data::CADOp; use std::collections::HashMap; use truck_modeling::*; @@ -59,13 +60,16 @@ fn wire_from_path(path: kurbo::BezPath, verts: &mut HashMap<(u64, u64), Vertex>) edges.into() } -fn face_from_paths(exterior: kurbo::BezPath, cutouts: Vec) -> Face { +fn face_from_paths(exterior: kurbo::BezPath, cutouts: Vec<(CADOp, kurbo::BezPath)>) -> Face { let mut verts: HashMap<(u64, u64), Vertex> = HashMap::with_capacity(32); let mut wires: Vec = Vec::with_capacity(1 + cutouts.len()); wires.push(wire_from_path(exterior, &mut verts)); - for p in cutouts.into_iter() { - wires.push(wire_from_path(p, &mut verts)); + for (op, path) in cutouts.into_iter() { + if !matches!(op, CADOp::Hole) { + panic!("unexpected op! {:?}", op); + } + wires.push(wire_from_path(path, &mut verts)); } builder::try_attach_plane(&wires).unwrap() @@ -73,7 +77,7 @@ fn face_from_paths(exterior: kurbo::BezPath, cutouts: Vec) -> Fa pub fn extrude_from_paths( exterior: kurbo::BezPath, - cutouts: Vec, + cutouts: Vec<(CADOp, kurbo::BezPath)>, height: f64, ) -> Solid { let face = face_from_paths(exterior, cutouts); @@ -134,7 +138,9 @@ pub fn solid_to_obj(s: Solid, tolerance: f64) -> Vec { let mut mesh = s.triangulation(tolerance).to_polygon(); use truck_meshalgo::filters::OptimizingFilter; - mesh.put_together_same_attrs(); + mesh.put_together_same_attrs() + .remove_degenerate_faces() + .remove_unused_attrs(); let mut out = Vec::with_capacity(1024); truck_polymesh::obj::write(&mesh, &mut out).unwrap(); diff --git a/liquid-cad/src/app.rs b/liquid-cad/src/app.rs index 7d91719..d1b4fd1 100644 --- a/liquid-cad/src/app.rs +++ b/liquid-cad/src/app.rs @@ -88,12 +88,22 @@ impl App { fn export_str_as(&mut self, type_name: &'static str, ext_name: &'static str, data: Vec) { #[cfg(not(target_arch = "wasm32"))] { + let file_name: String = match &self.last_path { + Some(pb) => { + format!("{}.{}", pb.file_stem().unwrap().to_str().unwrap(), ext_name).to_owned() + } + None => format!("export.{}", ext_name).to_owned(), + }; + use rfd::FileDialog; - let file = FileDialog::new() + let mut f = FileDialog::new() .add_filter(type_name, &[ext_name]) .add_filter("text", &["txt"]) - .set_file_name(format!("export.{}", ext_name)) - .save_file(); + .set_file_name(file_name); + if let Some(pb) = &self.last_path { + f = f.set_directory(pb.parent().unwrap()); + } + let file = f.save_file(); if let Some(path) = file { match std::fs::write(path.clone(), data) { @@ -138,11 +148,14 @@ impl App { #[cfg(not(target_arch = "wasm32"))] { use rfd::FileDialog; - let file = FileDialog::new() + let mut f = FileDialog::new() .add_filter("liquid cad", &["lcad"]) .add_filter("text", &["txt"]) - .set_file_name(file_name) - .save_file(); + .set_file_name(file_name); + if let Some(pb) = &self.last_path { + f = f.set_directory(pb.parent().unwrap()); + } + let file = f.save_file(); if let Some(path) = file { let sd = &self.drawing.serialize();