diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 362d848e..28ece5d3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -17,7 +17,7 @@ use std::sync::Arc; use serde::{Deserialize, Serialize}; use serde_json::Value; -use solvecraft_doc::{Document, Model}; +use solvecraft_doc::{Document, Model, ModelState}; use solvecraft_geom::Vec3; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; @@ -122,6 +122,16 @@ pub struct Snapshot { pub active_sketch: Option, } +/// What some commands would make, evaluated on a scratch copy of a session +/// ([`Session::preview`]). The session itself is not changed. +#[derive(Clone, Debug)] +pub struct Preview { + /// The model now. + pub before: Arc, + /// The model after the commands. + pub after: Arc, +} + /// An editing session on one design. pub struct Session { pub doc: Arc, @@ -240,6 +250,52 @@ impl Session { } } + /// A scratch copy of the session (same design and model, no history or log) to try + /// commands on without touching this one. + pub fn scratch(&self) -> Session { + Session { + doc: self.doc.clone(), + model: self.model.clone(), + undo: Vec::new(), + redo: Vec::new(), + active_sketch: self.active_sketch, + selection: Vec::new(), + path: None, + saved: self.saved.clone(), + log: Vec::new(), + revision: self.revision, + } + } + + /// 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 + /// being edited. + pub fn preview(&self, commands: &[(String, Value)]) -> Result { + self.scratch().preview_in_place(commands).map(|after| Preview { before: self.model.state(), after }) + } + + fn preview_in_place(mut self, commands: &[(String, Value)]) -> Result> { + if commands.is_empty() { + return Err(EngineError::Other("nothing to preview".into())); + } + for (id, params) in commands { + let r = self.execute(id, params)?; + if id == "timeline.redefine" + && let Some(fid) = r.get("feature").and_then(Value::as_u64) + && let Some(idx) = self.doc.feature_index(fid) + && self.doc.marker.is_some_and(|m| m <= idx) + { + self.doc_mut().marker = Some(idx + 1); + self.refresh(); + if let Some(e) = self.model.result(fid).and_then(|r| r.error.clone()) { + return Err(EngineError::Other(e)); + } + } + } + Ok(self.model.state()) + } + /// Run a script: a JSON array of `{"command": id, "params": {...}}` (or an object with a /// `commands` array). Stops at the first error. Returns each command's result. pub fn run_script(&mut self, script: &Value) -> Result> { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index b2db0a2c..c1d7647a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -473,3 +473,55 @@ fn step_insert_shell_and_errors() { assert!(d.execute("FusionImportCommandFromToolbar", &json!({"path": p.with_extension("stl").to_string_lossy()})).is_err()); assert_eq!(*d.doc, *before); } + +fn body_volume(st: &solvecraft_doc::ModelState) -> f64 { + st.bodies.iter().map(|b| solvecraft_kernel::measure(&b.body).unwrap().volume).sum() +} + +/// Previews evaluate on a scratch copy: the document, undo history and model are untouched. +#[test] +fn preview_has_no_side_effects() { + let mut s = Session::default(); + run(&mut s, "SketchCreate", json!({"plane": "XY"})); + run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]})); + run(&mut s, "SketchStop", json!({})); + let doc = s.doc.clone(); + let (undo, rev) = (s.undo.len(), s.revision); + let p = s.preview(&[("Extrude".into(), json!({"distance": 15}))]).unwrap(); + assert!(p.before.bodies.is_empty()); + assert!(rel(body_volume(&p.after), 40.0 * 30.0 * 15.0) < 1e-9); + assert!(Arc::ptr_eq(&doc, &s.doc)); + assert_eq!((s.undo.len(), s.revision), (undo, rev)); + assert!(s.model.state().bodies.is_empty()); + // Errors come back as errors and change nothing either. + assert!(s.preview(&[("Extrude".into(), json!({"distance": "nonsense"}))]).is_err()); + assert!(Arc::ptr_eq(&doc, &s.doc)); + + // Fillet preview on a real body. + run(&mut s, "Extrude", json!({"distance": 20})); + let (doc, undo) = (s.doc.clone(), s.undo.len()); + let p = s.preview(&[("FusionFilletEdgesCommand".into(), json!({"edges": [[0, 0, 10]], "radius": 3}))]).unwrap(); + let filleted = 24000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0; + assert!(rel(body_volume(&p.after), filleted) < 2e-4); + assert!(rel(body_volume(&p.before), 24000.0) < 1e-9); + assert!(Arc::ptr_eq(&doc, &s.doc)); + assert_eq!(s.undo.len(), undo); +} + +/// Previewing an edit shows the rebuilt feature although the timeline is rolled back to it. +#[test] +fn preview_of_an_edit() { + let mut s = Session::default(); + run(&mut s, "SketchCreate", json!({"plane": "XY"})); + run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]})); + run(&mut s, "SketchStop", json!({})); + let r = run(&mut s, "Extrude", json!({"distance": 20})); + let fid = r["feature"].as_u64().unwrap(); + let idx = s.doc.feature_index(fid).unwrap(); + run(&mut s, "timeline.rollTo", json!({"position": idx})); + assert!(s.model.state().bodies.is_empty()); + let doc = s.doc.clone(); + let p = s.preview(&[("timeline.redefine".into(), json!({"feature": fid, "command": "Extrude", "params": {"distance": 10}}))]).unwrap(); + assert!(rel(body_volume(&p.after), 12000.0) < 1e-9); + assert!(Arc::ptr_eq(&doc, &s.doc)); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ee2262f0..12845b22 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -210,6 +210,22 @@ impl Dialog { } } + /// Does this dialog make a feature that can be previewed live? + pub fn previews(&self) -> bool { + matches!( + self.kind, + Kind::Extrude { .. } + | Kind::Revolve { .. } + | Kind::Fillet { .. } + | Kind::Shell { .. } + | Kind::Draft { .. } + | Kind::Mirror + | Kind::Hole { .. } + | Kind::Primitive { .. } + | Kind::Combine { .. } + ) + } + pub fn wants_picks(&self) -> bool { !self.inputs.is_empty() } @@ -574,9 +590,9 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } } } - if let Some(e) = &d.error { + if let Some(e) = d.error.as_ref().or(app.preview.error.as_ref()) { ui.label(""); - ui.label(RichText::new(e.as_str()).color(t.error)); + ui.add(egui::Label::new(RichText::new(e.as_str()).color(t.error)).wrap()); ui.end_row(); } }); @@ -720,17 +736,22 @@ fn sels(d: &Dialog, i: usize) -> &[Sel] { } fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> { - let cmds = dialog_commands(app, d)?; - if let Some((id, _)) = d.editing { - let (cmd, params) = cmds.into_iter().next().ok_or("nothing to apply")?; - return app.run("timeline.redefine", json!({"feature": id, "command": cmd, "params": params})).map(drop); - } - for (cmd, params) in cmds { + for (cmd, params) in apply_commands(app, d)? { app.run(&cmd, params)?; } Ok(()) } +/// The commands OK runs (an edit rebuilds its feature in place); the live preview runs the same. +pub fn apply_commands(app: &SolveApp, d: &Dialog) -> Result, String> { + let cmds = dialog_commands(app, d)?; + if let Some((id, _)) = d.editing { + let (cmd, params) = cmds.into_iter().next().ok_or("nothing to apply")?; + return Ok(vec![("timeline.redefine".into(), json!({"feature": id, "command": cmd, "params": params}))]); + } + Ok(cmds) +} + /// The commands (id, parameters) a dialog's OK runs. fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, String> { let s = &app.session; @@ -932,7 +953,7 @@ fn face_sel(s: &Session, p: Vec3) -> Option { let m = b.mesh(); for (t, f) in m.triangles.iter().zip(&m.tri_face) { let Some([a, bb, c]) = m.tri(t) else { continue }; - let d = point_triangle_dist(p, a, bb, c); + let d = crate::preview::point_triangle_dist(p, a, bb, c); if best.as_ref().is_none_or(|(bd, _)| d < *bd) { best = Some((d, Sel::Face { body: b.name.clone(), index: *f as usize, point: p })); } @@ -941,15 +962,6 @@ fn face_sel(s: &Session, p: Vec3) -> Option { best.filter(|(d, _)| *d < 1e-3 + 1e-6 * p.len()).map(|x| x.1) } -fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 { - let n = (b - a).cross(c - a); - let Some(nn) = n.normalized() else { return f64::INFINITY }; - let h = (p - a).dot(nn); - let q = p - nn * h; - let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= -1e-12); - if inside { h.abs() } else { [(a, b), (b, c), (c, a)].iter().map(|(u, v)| p.dist_to_segment(*u, *v)).fold(f64::INFINITY, f64::min) } -} - fn plane_sel(s: &Session, pl: &PlaneRef) -> Option { match pl { PlaneRef::Origin { name } | PlaneRef::Construction { name } => Some(Sel::Plane { name: name.clone() }), diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index bf2b0647..2a2bc95d 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -26,6 +26,9 @@ pub struct GpuScene { pub lines: Vec, /// Lines drawn over everything (active sketch, highlights). pub overlay: Vec, + /// Translucent triangles pushed slightly back in depth, so they only show where nothing + /// coincides with them (removed material in a preview). + pub ghost: Vec, } impl GpuScene { @@ -41,6 +44,12 @@ impl GpuScene { } self.trans.extend_from_slice(&c); } + pub fn ghost_tri(&mut self, p: [f32; 3], n: [f32; 3], c: [u8; 4]) { + for v in p.iter().chain(&n) { + self.ghost.extend_from_slice(&v.to_le_bytes()); + } + self.ghost.extend_from_slice(&c); + } pub fn line(&mut self, a: [f32; 3], b: [f32; 3], c: [u8; 4], width: f32, on_top: bool) { let out = if on_top { &mut self.overlay } else { &mut self.lines }; for v in a.iter().chain(&b) { @@ -60,6 +69,9 @@ pub struct ViewportCallback { /// The highlight scene and its key. pub hl_key: u64, pub hl_slot: SceneSlot, + /// The live preview scene and its key. + pub pv_key: u64, + pub pv_slot: SceneSlot, /// Column-major view-projection. pub view_proj: [[f32; 4]; 4], /// Direction toward the eye (for lighting). @@ -80,6 +92,7 @@ struct Batches { trans: Option, lines: Option, overlays: Option, + ghost: Option, } impl Batches { @@ -92,6 +105,7 @@ impl Batches { self.trans = upload(device, "sc_trans", &sc.trans, TRI_SIZE); self.lines = upload(device, "sc_lines", &sc.lines, LINE_SIZE); self.overlays = upload(device, "sc_overlay", &sc.overlay, LINE_SIZE); + self.ghost = upload(device, "sc_ghost", &sc.ghost, TRI_SIZE); self.key = Some(key); } } @@ -102,6 +116,7 @@ struct Resources { /// Highlight triangles over the model (depth test ≤, no depth write). tri_hl: wgpu::RenderPipeline, trans: wgpu::RenderPipeline, + ghost: wgpu::RenderPipeline, line: wgpu::RenderPipeline, overlay: wgpu::RenderPipeline, uniform: wgpu::Buffer, @@ -109,6 +124,7 @@ struct Resources { linear_out: bool, model: Batches, highlight: Batches, + preview: Batches, } const SHADER: &str = r#" @@ -163,6 +179,16 @@ fn fs_tri(i: TOut, @builtin(front_facing) front: bool) -> @location(0) vec4 return out_color(vec4(min(i.c.rgb * k + vec3(spec), vec3(1.0)), i.c.a)); } +@vertex +fn vs_ghost(@location(0) p: vec3, @location(1) n: vec3, @location(2) c: vec4) -> TOut { + var o: TOut; + o.pos = u.vp * vec4(p, 1.0); + o.pos.z = o.pos.z + u.screen.z * 4.0 * o.pos.w; + o.n = n; + o.c = c; + return o; +} + struct LOut { @builtin(position) pos: vec4, @location(0) c: vec4, @@ -282,6 +308,7 @@ impl Resources { tri: pipeline("sc_tris", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::Less, true), None), tri_hl: pipeline("sc_tris_hl", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), trans: pipeline("sc_trans", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), + ghost: pipeline("sc_ghost", "vs_ghost", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), line: pipeline("sc_lines", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), overlay: pipeline("sc_overlay", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::Always, false), alpha), uniform, @@ -289,6 +316,7 @@ impl Resources { linear_out: t.format.is_srgb(), model: Batches::default(), highlight: Batches::default(), + preview: Batches::default(), } } } @@ -327,6 +355,7 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { let Some(res) = resources.get_mut::() else { return Vec::new() }; res.model.take(device, self.key, &self.slot); res.highlight.take(device, self.hl_key, &self.hl_slot); + res.preview.take(device, self.pv_key, &self.pv_slot); queue.write_buffer(&res.uniform, 0, &uniform_bytes(self, self.size_px[0], self.size_px[1], res.linear_out)); Vec::new() } @@ -353,11 +382,14 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { pass.draw(0..6, 0..b.count); } }; - let (m, h) = (&res.model, &res.highlight); + let (m, h, pv) = (&res.model, &res.highlight, &res.preview); tris(pass, &res.tri, &m.tris); + tris(pass, &res.tri, &pv.tris); tris(pass, &res.tri_hl, &h.tris); lines(pass, &res.line, &m.lines); + lines(pass, &res.line, &pv.lines); lines(pass, &res.line, &h.lines); + tris(pass, &res.ghost, &pv.ghost); tris(pass, &res.trans, &m.trans); tris(pass, &res.trans, &h.trans); lines(pass, &res.overlay, &m.overlays); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e01f27cc..80eeff9d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -14,6 +14,7 @@ pub mod dialogs; pub mod gpu; pub mod icons; pub mod palette; +pub mod preview; pub mod selection; pub mod theme; pub mod timeline; @@ -87,6 +88,7 @@ pub struct SolveApp { pub tool: Option, pub dialog: Option, pub palette: palette::Palette, + pub preview: preview::PreviewState, pub status: Option<(String, f64, bool)>, pub quit_requested: bool, pub frame_ms: f64, @@ -113,6 +115,7 @@ impl SolveApp { tool: None, dialog: None, palette: palette::Palette::default(), + preview: preview::PreviewState::default(), status: None, quit_requested: false, frame_ms: 0.0, @@ -387,6 +390,7 @@ impl SolveApp { if self.ui.show_browser { browser::browser(self, ui); } + preview::update(self, ui.ctx()); egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { viewport::show(self, ui); }); diff --git a/crates/ui-egui/src/preview.rs b/crates/ui-egui/src/preview.rs new file mode 100644 index 00000000..00a50329 --- /dev/null +++ b/crates/ui-egui/src/preview.rs @@ -0,0 +1,350 @@ +//! Live operation previews. While a feature dialog is open, its commands are evaluated on a +//! scratch copy of the session ([`solvecraft_engine::Session::preview`]), off the UI thread +//! where threads exist, and the result is drawn in place of the bodies it changes: new surface +//! tinted, removed material as a translucent ghost. The last good preview stays up while the +//! next one computes or when an input makes the feature fail (the error shows in the dialog). +//! Nothing here touches the document or the undo history. + +use std::hash::{Hash, Hasher}; +use std::sync::mpsc::{Receiver, TryRecvError}; + +use serde_json::Value; +use solvecraft_engine::Session; +use solvecraft_engine::doc::ModelState; +use solvecraft_engine::geom::{Mesh, Vec3}; + +use crate::SolveApp; +use crate::gpu::{GpuScene, SceneSlot}; +use crate::theme::Tokens; + +/// Colours a preview is drawn with (straight sRGBA). +#[derive(Clone, Copy, Debug)] +pub struct Colors { + pub body: [u8; 4], + pub added: [u8; 4], + pub removed: [u8; 4], + pub edge: [u8; 4], +} + +impl Colors { + pub fn from_tokens(t: &Tokens) -> Colors { + let c = |c: egui::Color32| c.to_srgba_unmultiplied(); + Colors { body: c(t.body), added: c(t.preview_add), removed: c(t.preview_cut), edge: c(t.body_edge) } + } +} + +/// A computed preview: the scene to draw and the bodies it stands in for. +pub struct Built { + pub scene: GpuScene, + pub replaced: Vec, + /// Why the feature failed, when the scene only shows its tool body. + pub error: Option, +} + +type Job = Result; + +#[derive(Default)] +pub struct PreviewState { + /// Key of the commands whose result (or error) is shown. + shown_key: Option, + running: Option<(u64, Receiver)>, + /// Bodies the preview stands in for (hidden in the model scene). + pub replaced: Vec, + /// The preview scene for the GPU and its key (changes whenever the scene does). + pub slot: SceneSlot, + pub key: u64, + /// Is a preview scene up? + pub active: bool, + /// Why the current inputs don't make a feature (the last good preview stays up). + pub error: Option, + /// A preview is being computed. + pub busy: bool, + /// Time the last preview took (ms), for `ui.inspect`. + pub ms: f64, + started: f64, +} + +impl PreviewState { + fn clear(&mut self) { + self.running = None; + self.shown_key = None; + self.error = None; + self.busy = false; + if self.active || !self.replaced.is_empty() { + self.active = false; + self.replaced.clear(); + self.set_scene(GpuScene::default()); + } + } + + fn set_scene(&mut self, sc: GpuScene) { + if let Ok(mut slot) = self.slot.lock() { + *slot = Some(sc); + } + self.key = self.key.wrapping_add(1); + } + + fn apply(&mut self, key: u64, job: Job) { + self.shown_key = Some(key); + self.ms = crate::now_ms() - self.started; + match job { + Ok(b) => { + self.replaced = b.replaced; + self.active = true; + self.error = b.error; + self.set_scene(b.scene); + } + Err(e) => self.error = Some(e), + } + } +} + +/// Keep the preview in step with the open dialog: start a computation when its inputs changed, +/// take finished results. Called once per frame. +pub fn update(app: &mut SolveApp, ctx: &egui::Context) { + let cmds = match app.dialog.as_ref().filter(|d| d.previews()).map(|d| crate::dialogs::apply_commands(app, d)) { + Some(Ok(c)) if !c.is_empty() => c, + // No dialog, or inputs still missing: nothing to preview. + _ => { + app.preview.clear(); + return; + } + }; + let colors = Colors::from_tokens(&Tokens::get()); + let mut h = std::collections::hash_map::DefaultHasher::new(); + serde_json::to_string(&cmds).unwrap_or_default().hash(&mut h); + app.session.revision.hash(&mut h); + format!("{colors:?}").hash(&mut h); + let key = h.finish(); + let pv = &mut app.preview; + if let Some((k, rx)) = &pv.running { + let k = *k; + match rx.try_recv() { + Ok(job) => { + pv.running = None; + pv.apply(k, job); + } + Err(TryRecvError::Empty) => {} + Err(TryRecvError::Disconnected) => { + pv.running = None; + pv.apply(k, Err("the preview failed".into())); + } + } + } + if pv.running.is_none() && pv.shown_key != Some(key) { + pv.started = crate::now_ms(); + let scratch = app.session.scratch(); + spawn(&mut app.preview, key, scratch, cmds, colors); + } + app.preview.busy = app.preview.running.is_some(); + if app.preview.busy { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn spawn(pv: &mut PreviewState, key: u64, scratch: Session, cmds: Vec<(String, Value)>, colors: Colors) { + let (tx, rx) = std::sync::mpsc::channel(); + let spawned = std::thread::Builder::new().name("preview".into()).spawn(move || { + let _ = tx.send(compute(&scratch, &cmds, colors)); + }); + match spawned { + Ok(_) => pv.running = Some((key, rx)), + Err(e) => pv.apply(key, Err(e.to_string())), + } +} + +/// No threads on the web: compute right away. +#[cfg(target_arch = "wasm32")] +fn spawn(pv: &mut PreviewState, key: u64, scratch: Session, cmds: Vec<(String, Value)>, colors: Colors) { + pv.apply(key, compute(&scratch, &cmds, colors)); +} + +/// Evaluate the commands on the scratch session and build the preview scene. +/// When the feature itself fails (a boolean the kernel can't do yet), the tool body alone is +/// shown with the error, so the shape can still be judged. +pub fn compute(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Job { + let (p, error) = match s.preview(cmds) { + Ok(p) => (p, None), + Err(e) => match as_new_body(cmds).and_then(|alt| s.preview(&alt).ok()) { + Some(p) => (p, Some(e.to_string())), + None => return Err(e.to_string()), + }, + }; + let mut b = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| build(&p.before, &p.after, colors))) + .map_err(|_| "the preview failed".to_string())?; + b.error = error; + Ok(b) +} + +/// The same commands making a new body instead of joining, cutting or intersecting. +fn as_new_body(cmds: &[(String, Value)]) -> Option> { + let mut out = cmds.to_vec(); + let mut changed = false; + for (_, p) in &mut out { + let p = if p.get("command").is_some() { p.get_mut("params")? } else { p }; + if let Some(op) = p.get_mut("operation") + && op.as_str().is_some_and(|o| o != "new") + { + *op = Value::from("new"); + changed = true; + } + } + changed.then_some(out) +} + +fn same(a: &Mesh, b: &Mesh) -> bool { + a.triangles.len() == b.triangles.len() && a.positions == b.positions +} + +/// The preview scene: changed and new bodies (new surface tinted) and, as a ghost, the old +/// version of changed or removed bodies. +pub fn build(before: &ModelState, after: &ModelState, c: Colors) -> Built { + let mut sc = GpuScene::default(); + let mut replaced = Vec::new(); + for a in &after.bodies { + let am = a.mesh(); + let old = before.body(&a.name).map(|b| b.mesh()); + if old.as_ref().is_some_and(|bm| same(bm, &am)) { + continue; + } + let nf = am.tri_face.iter().max().map_or(0, |m| *m as usize + 1); + let added: Vec = (0..nf).map(|f| old.as_ref().is_none_or(|bm| face_is_new(&am, f, bm))).collect(); + for (t, f) in am.triangles.iter().zip(&am.tri_face) { + let col = if added.get(*f as usize).copied().unwrap_or(true) { c.added } else { c.body }; + for k in t { + let i = *k as usize; + if let (Some(p), Some(n)) = (am.positions.get(i), am.normals.get(i)) { + sc.tri(p.to_f32(), n.to_f32(), col); + } + } + } + for (ei, e) in am.edges.iter().enumerate() { + if am.seams.get(ei).copied().unwrap_or(false) { + continue; + } + for w in e.windows(2) { + sc.line(w[0].to_f32(), w[1].to_f32(), c.edge, 1.3, false); + } + } + if old.is_some() { + replaced.push(a.name.clone()); + } + } + for b in &before.bodies { + let gone = after.body(&b.name).is_none(); + if !gone && !replaced.contains(&b.name) { + continue; + } + let m = b.mesh(); + for t in &m.triangles { + for k in t { + let i = *k as usize; + if let (Some(p), Some(n)) = (m.positions.get(i), m.normals.get(i)) { + sc.ghost_tri(p.to_f32(), n.to_f32(), c.removed); + } + } + } + if gone { + replaced.push(b.name.clone()); + } + } + Built { scene: sc, replaced, error: None } +} + +/// Is face `f` of `m` new surface, i.e. not on the old mesh? Decided by a vote of points on it +/// (triangle centroids spread over the face). +fn face_is_new(m: &Mesh, f: usize, old: &Mesh) -> bool { + let cents: Vec = m + .triangles + .iter() + .zip(&m.tri_face) + .filter(|(_, tf)| **tf as usize == f) + .filter_map(|(t, _)| m.tri(t)) + .map(|[a, b, c]| (a + b + c) / 3.0) + .collect(); + if cents.is_empty() { + return true; + } + let step = cents.len().div_ceil(9); + let samples: Vec<&Vec3> = cents.iter().step_by(step.max(1)).collect(); + let off = samples.iter().filter(|p| !on_surface(old, ***p)).count(); + off * 3 > samples.len() +} + +/// Does `p` lie on the surface of mesh `m` (within its tessellation tolerance)? +fn on_surface(m: &Mesh, p: Vec3) -> bool { + let bb = m.bounds(); + let tol = (bb.diagonal() * 2e-3).max(1e-4); + if bb.is_empty() { + return false; + } + m.triangles.iter().filter_map(|t| m.tri(t)).any(|[a, b, c]| point_triangle_dist(p, a, b, c) < tol) +} + +pub fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 { + let n = (b - a).cross(c - a); + let Some(nn) = n.normalized() else { return f64::INFINITY }; + let h = (p - a).dot(nn); + let q = p - nn * h; + let inside = [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (*v - *u).cross(q - *u).dot(n) >= -1e-12); + if inside { h.abs() } else { [(a, b), (b, c), (c, a)].iter().map(|(u, v)| p.dist_to_segment(*u, *v)).fold(f64::INFINITY, f64::min) } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn colors() -> Colors { + Colors { body: [1, 1, 1, 255], added: [2, 2, 2, 255], removed: [3, 3, 3, 100], edge: [0, 0, 0, 255] } + } + + fn count(bytes: &[u8], stride: usize, col: [u8; 4]) -> usize { + bytes.chunks(stride).filter(|v| v.get(24..28) == Some(&col[..])).count() + } + + fn plate() -> Session { + let mut s = Session::default(); + for (id, p) in [ + ("SketchCreate", json!({"plane": "XY"})), + ("ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [40, 30]})), + ("SketchStop", json!({})), + ("Extrude", json!({"distance": 20})), + ] { + s.execute(id, &p).unwrap(); + } + s + } + + /// A fillet preview stands in for the body: the round face is new, the rest is kept, and the + /// old body is the ghost. The session is unchanged. + #[test] + fn fillet_preview_scene() { + let s = plate(); + let (rev, undo) = (s.revision, s.undo.len()); + let b = compute(&s, &[("FusionFilletEdgesCommand".into(), json!({"edges": [[0, 0, 10]], "radius": 3}))], colors()).unwrap(); + assert_eq!(b.replaced, vec!["Body1".to_string()]); + let added = count(&b.scene.tris, crate::gpu::TRI_SIZE, colors().added); + let kept = count(&b.scene.tris, crate::gpu::TRI_SIZE, colors().body); + assert!(added > 0 && kept > 0, "added {added} kept {kept}"); + assert!(!b.scene.ghost.is_empty()); + assert_eq!(s.revision, rev); + assert_eq!(s.undo.len(), undo); + } + + /// A new body is all new surface and replaces nothing; a bad value is an error. + #[test] + fn extrude_preview_scene() { + let mut s = Session::default(); + s.execute("SketchCreate", &json!({"plane": "XY"})).unwrap(); + s.execute("CircleCenterRadius", &json!({"center": [0, 0], "radius": 5})).unwrap(); + s.execute("SketchStop", &json!({})).unwrap(); + let b = compute(&s, &[("Extrude".into(), json!({"distance": "15 mm"}))], colors()).unwrap(); + assert!(b.replaced.is_empty()); + assert_eq!(count(&b.scene.tris, crate::gpu::TRI_SIZE, colors().body), 0); + assert!(b.scene.ghost.is_empty()); + assert!(compute(&s, &[("Extrude".into(), json!({"distance": "1 +"}))], colors()).is_err()); + assert!(s.model.state().bodies.is_empty()); + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index a144b9e7..6b99b6f2 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -51,6 +51,13 @@ pub struct Tokens { pub axis_z: Color32, /// Construction planes. pub construction_plane: Color32, + // ---- bodies and live previews ---- + pub body: Color32, + pub body_edge: Color32, + /// New surface in a feature preview. + pub preview_add: Color32, + /// Removed material in a feature preview (translucent). + pub preview_cut: Color32, // ---- box selection ---- pub box_window: Color32, pub box_crossing: Color32, @@ -95,6 +102,10 @@ impl Tokens { axis_y: Color32::from_rgb(0, 255, 0), axis_z: Color32::from_rgb(2, 2, 248), construction_plane: Color32::from_rgba_unmultiplied_const(249, 184, 134, 70), + body: Color32::from_rgb(176, 186, 198), + body_edge: Color32::from_rgb(40, 44, 52), + preview_add: Color32::from_rgb(112, 160, 226), + preview_cut: Color32::from_rgba_unmultiplied_const(232, 84, 44, 110), box_window: Color32::from_rgb(38, 120, 218), box_crossing: Color32::from_rgb(40, 160, 80), } diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 2ea2377a..ffaa6e91 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -143,6 +143,7 @@ fn scene_key(app: &SolveApp) -> u64 { app.ui.show_grid.hash(&mut h); app.ui.show_sketches.hash(&mut h); app.ui.hidden_bodies.hash(&mut h); + app.preview.replaced.hash(&mut h); app.session.active_sketch.hash(&mut h); let (minor, _) = grid_step(app.cam.half_height()); minor.to_bits().hash(&mut h); @@ -170,7 +171,7 @@ fn build_scene(app: &SolveApp) -> GpuScene { } } for b in &st.bodies { - if app.ui.hidden_bodies.contains(&b.name) { + if app.ui.hidden_bodies.contains(&b.name) || app.preview.replaced.contains(&b.name) { continue; } let col = rgba(colors::BODY); @@ -504,6 +505,7 @@ fn highlight_key(app: &SolveApp) -> u64 { serde_json::to_string(&app.highlighted()).unwrap_or_default().hash(&mut h); format!("{:?}", app.viewport.hover).hash(&mut h); app.viewport.hover_feature.hash(&mut h); + app.preview.replaced.hash(&mut h); app.origin_visible().hash(&mut h); app.ui.hidden_origin.hash(&mut h); app.ui.hidden_bodies.hash(&mut h); @@ -560,7 +562,8 @@ fn build_highlight(app: &SolveApp) -> GpuScene { for x in &sel { match x { Sel::Face { body, index, .. } => { - if let Some(b) = st.body(body) { + // A live preview stands in for the body: its old faces would fight with it. + if let Some(b) = st.body(body).filter(|_| !app.preview.replaced.contains(body)) { face_tris(&mut sc, &b.mesh(), *index, c4(t.sel_face), false); } } @@ -621,7 +624,10 @@ fn build_highlight(app: &SolveApp) -> GpuScene { } // Hover (pre-highlight). match hover { - Some(Hit::Face { body, index, .. }) if !sel.iter().any(|x| matches!(x, Sel::Face { body: b, index: i, .. } if b == body && i == index)) => { + Some(Hit::Face { body, index, .. }) + if !app.preview.replaced.contains(body) + && !sel.iter().any(|x| matches!(x, Sel::Face { body: b, index: i, .. } if b == body && i == index)) => + { if let Some(b) = st.body(body) { let k = t.hover_face_lift; let c = colors::BODY; @@ -821,6 +827,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { slot: app.viewport.slot.clone(), hl_key: app.viewport.hl_key, hl_slot: app.viewport.hl_slot.clone(), + pv_key: app.preview.key, + pv_slot: app.preview.slot.clone(), view_proj: proj.vp.to_f32(), back: proj.cam.back().to_f32(), size_px: [rect.width() * ppp, rect.height() * ppp],