Live operation previews: open feature dialogs (new or Edit Feature) evaluate on a scratch session off the UI thread (Session::preview, no document or undo changes) and draw the result in place: new surface tinted, removed material as a translucent ghost; errors show in the dialog and keep the last good preview; a failing join/cut still shows its tool body

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 21:09:16 +00:00
1 parent b8d0fcc8e2
commit d44083d1cc
8 files changed
+548 -23

No files matched your search

+57 -1
View File
@@ -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<u64>,
}
/// 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<ModelState>,
/// The model after the commands.
pub after: Arc<ModelState>,
}
/// An editing session on one design.
pub struct Session {
pub doc: Arc<Document>,
@@ -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<Preview> {
self.scratch().preview_in_place(commands).map(|after| Preview { before: self.model.state(), after })
}
fn preview_in_place(mut self, commands: &[(String, Value)]) -> Result<Arc<ModelState>> {
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<Vec<Value>> {
+52
View File
@@ -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));
}
+30 -18
View File
@@ -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<Vec<(String, Value)>, 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<Vec<(String, Value)>, String> {
let s = &app.session;
@@ -932,7 +953,7 @@ fn face_sel(s: &Session, p: Vec3) -> Option<Sel> {
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<Sel> {
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<Sel> {
match pl {
PlaneRef::Origin { name } | PlaneRef::Construction { name } => Some(Sel::Plane { name: name.clone() }),
+33 -1
View File
@@ -26,6 +26,9 @@ pub struct GpuScene {
pub lines: Vec<u8>,
/// Lines drawn over everything (active sketch, highlights).
pub overlay: Vec<u8>,
/// Translucent triangles pushed slightly back in depth, so they only show where nothing
/// coincides with them (removed material in a preview).
pub ghost: Vec<u8>,
}
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<Batch>,
lines: Option<Batch>,
overlays: Option<Batch>,
ghost: Option<Batch>,
}
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<f32>
return out_color(vec4<f32>(min(i.c.rgb * k + vec3<f32>(spec), vec3<f32>(1.0)), i.c.a));
}
@vertex
fn vs_ghost(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) c: vec4<f32>) -> TOut {
var o: TOut;
o.pos = u.vp * vec4<f32>(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<f32>,
@location(0) c: vec4<f32>,
@@ -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::<Resources>() 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);
+4
View File
@@ -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<tools::Tool>,
pub dialog: Option<dialogs::Dialog>,
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);
});
+350
View File
@@ -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<String>,
/// Why the feature failed, when the scene only shows its tool body.
pub error: Option<String>,
}
type Job = Result<Built, String>;
#[derive(Default)]
pub struct PreviewState {
/// Key of the commands whose result (or error) is shown.
shown_key: Option<u64>,
running: Option<(u64, Receiver<Job>)>,
/// Bodies the preview stands in for (hidden in the model scene).
pub replaced: Vec<String>,
/// 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<String>,
/// 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<Vec<(String, Value)>> {
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<bool> = (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<Vec3> = 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());
}
}
+11
View File
@@ -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),
}
+11 -3
View File
@@ -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],