1031 lines
44 KiB
Rust
1031 lines
44 KiB
Rust
//! Live operation previews. While a feature dialog is open, its commands are evaluated on a
|
||
//! scratch copy of the session ([`solidcraft_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 solidcraft_engine::Session;
|
||
use solidcraft_engine::doc::ModelState;
|
||
use solidcraft_engine::geom::{Aabb3, 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) }
|
||
}
|
||
}
|
||
|
||
pub struct SketchPreview {
|
||
pub sketch: std::sync::Arc<solidcraft_engine::doc::SolvedSketch>,
|
||
pub curves: Vec<usize>,
|
||
pub points: Vec<usize>,
|
||
}
|
||
|
||
/// A computed preview: the scene to draw and the bodies it stands in for.
|
||
pub struct Built {
|
||
pub scene: GpuScene,
|
||
pub sketches: Vec<SketchPreview>,
|
||
pub replaced: Vec<String>,
|
||
/// Why the feature failed, when the scene only shows its tool body.
|
||
pub error: Option<String>,
|
||
/// Extent of what the preview shows (the view's depth range must cover it).
|
||
pub bounds: Aabb3,
|
||
/// The smallest and largest radius a fillet used when it reduced one too large for its edges
|
||
/// (`fit: reduce`).
|
||
pub applied: Option<(f64, f64)>,
|
||
/// The radius spline of each edge a fillet tapered (`fit: taper`), with a point on the edge.
|
||
pub tapered: Vec<(Vec3, solidcraft_engine::kernel::Taper)>,
|
||
/// The reply of a previewed drive that stopped short of the asked values (the dialog says
|
||
/// where and why, in the UI's language).
|
||
pub stopped: Option<serde_json::Value>,
|
||
}
|
||
|
||
type Job = Result<Built, String>;
|
||
|
||
/// What the preview worker sends: for a feature that adds or removes a tool body, the tool as
|
||
/// soon as it is built (the boolean still running), then the finished preview.
|
||
enum Msg {
|
||
Partial(Built),
|
||
Done(Job),
|
||
}
|
||
|
||
#[derive(Default)]
|
||
pub struct PreviewState {
|
||
/// Key of the commands whose result (or error) is shown.
|
||
shown_key: Option<u64>,
|
||
running: Option<(u64, Receiver<Msg>)>,
|
||
/// Inputs of an in-flight joint preview, excluding only its driven values.
|
||
running_motion_key: Option<u64>,
|
||
/// 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,
|
||
pub sketches: Vec<SketchPreview>,
|
||
/// 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,
|
||
/// Extent of the preview scene.
|
||
pub bounds: Aabb3,
|
||
/// Time the last preview took (ms), for `ui.inspect`.
|
||
pub ms: f64,
|
||
started: f64,
|
||
/// The smallest and largest radius the previewed fillet used when it reduced the one asked for.
|
||
pub applied: Option<(f64, f64)>,
|
||
/// The reply of a previewed drive that stopped short (see [`Built::stopped`]).
|
||
pub stopped: Option<serde_json::Value>,
|
||
/// The radius splines the previewed fillet tapered its edges with.
|
||
pub tapered: Vec<(Vec3, solidcraft_engine::kernel::Taper)>,
|
||
}
|
||
|
||
impl PreviewState {
|
||
fn clear(&mut self) {
|
||
self.running = None;
|
||
self.running_motion_key = None;
|
||
self.shown_key = None;
|
||
self.error = None;
|
||
self.applied = None;
|
||
self.stopped = None;
|
||
self.tapered.clear();
|
||
self.sketches.clear();
|
||
self.busy = false;
|
||
if self.active || !self.replaced.is_empty() {
|
||
self.active = false;
|
||
self.bounds = Aabb3::EMPTY;
|
||
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.sketches = b.sketches;
|
||
self.replaced = b.replaced;
|
||
self.bounds = b.bounds;
|
||
self.active = true;
|
||
self.error = b.error;
|
||
self.applied = b.applied;
|
||
self.stopped = b.stopped;
|
||
self.tapered = b.tapered;
|
||
self.set_scene(b.scene);
|
||
}
|
||
Err(e) => {
|
||
self.sketches.clear();
|
||
self.error = Some(e);
|
||
self.applied = None;
|
||
self.stopped = None;
|
||
self.tapered.clear();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Errors validate only the current inputs. Keep the last good geometry while
|
||
/// computing new inputs, but never let an obsolete result block their Apply.
|
||
fn poll(&mut self, key: u64, motion_key: Option<u64>) {
|
||
if self.shown_key != Some(key) {
|
||
self.sketches.clear();
|
||
self.error = None;
|
||
}
|
||
while let Some((k, rx)) = &self.running {
|
||
let k = *k;
|
||
let job = match rx.try_recv() {
|
||
// The tool, the boolean still running: shown, and the preview stays busy.
|
||
Ok(Msg::Partial(b)) => {
|
||
if k == key {
|
||
self.apply(k, Ok(b));
|
||
}
|
||
continue;
|
||
}
|
||
Ok(Msg::Done(job)) => Some(job),
|
||
Err(TryRecvError::Empty) => None,
|
||
Err(TryRecvError::Disconnected) => Some(Err("the preview failed".into())),
|
||
};
|
||
let Some(job) = job else { break };
|
||
{
|
||
self.running = None;
|
||
let intermediate = motion_key.is_some() && self.running_motion_key == motion_key && matches!(&job, Ok(b) if b.error.is_none());
|
||
self.running_motion_key = None;
|
||
// A held slider or animation may advance faster than the worker. Show
|
||
// completed poses from the same joint setup, then compute the latest.
|
||
// Errors and results from different setups still require an exact key.
|
||
if k == key || intermediate {
|
||
self.apply(k, job);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Joint motion can stream successful intermediate poses while values change. Include
|
||
/// every other input, the document revision and colours so a different joint, changed
|
||
/// limits, feature dialog or document cannot reuse an obsolete pose.
|
||
fn motion_key(cmds: &[(String, Value)], revision: u64, colors: Colors) -> Option<u64> {
|
||
if !cmds.iter().any(|(c, p)| c == "joint.drive" || c == "joint.create" && p.get("values").is_some())
|
||
|| !cmds.iter().all(|(c, _)| matches!(c.as_str(), "joint.drive" | "joint.create" | "joint.as_built" | "joint.edit" | "joint.limits"))
|
||
{
|
||
return None;
|
||
}
|
||
let mut setup = cmds.to_vec();
|
||
for (command, params) in &mut setup {
|
||
if matches!(command.as_str(), "joint.drive" | "joint.create")
|
||
&& let Some(p) = params.as_object_mut()
|
||
{
|
||
p.remove("values");
|
||
if command == "joint.drive" {
|
||
p.remove("value");
|
||
}
|
||
}
|
||
}
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
serde_json::to_string(&setup).ok()?.hash(&mut h);
|
||
revision.hash(&mut h);
|
||
format!("{colors:?}").hash(&mut h);
|
||
Some(h.finish())
|
||
}
|
||
|
||
/// 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::preview_commands(app, d)) {
|
||
Some(Ok(c)) if !c.is_empty() => c,
|
||
_ if crate::sketch_attachment_ui::commands(app).is_some() => crate::sketch_attachment_ui::commands(app).unwrap_or_default(),
|
||
// A parameter being edited in Change Parameters.
|
||
_ if app.params.as_ref().and_then(|p| p.preview_commands()).is_some() => {
|
||
app.params.as_ref().and_then(|p| p.preview_commands()).unwrap_or_default()
|
||
}
|
||
// No dialog, or inputs still missing: nothing to preview.
|
||
_ => {
|
||
app.preview.clear();
|
||
return;
|
||
}
|
||
};
|
||
let colors = Colors::from_tokens(&Tokens::get());
|
||
let key = job_key(&cmds, app.session.revision, colors);
|
||
let motion_key = motion_key(&cmds, app.session.revision, colors);
|
||
let pv = &mut app.preview;
|
||
pv.poll(key, motion_key);
|
||
if pv.running.is_none() && pv.shown_key != Some(key) {
|
||
pv.started = crate::now_ms();
|
||
pv.running_motion_key = motion_key;
|
||
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));
|
||
}
|
||
}
|
||
|
||
/// What tells one preview computation from another: its commands, the document revision they
|
||
/// run on and the colours.
|
||
fn job_key(cmds: &[(String, Value)], revision: u64, colors: Colors) -> u64 {
|
||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||
serde_json::to_string(cmds).unwrap_or_default().hash(&mut h);
|
||
revision.hash(&mut h);
|
||
format!("{colors:?}").hash(&mut h);
|
||
h.finish()
|
||
}
|
||
|
||
/// Before OK runs `cmds`: when the preview is still computing those very commands, wait for it
|
||
/// rather than compute them a second time alongside it. What it evaluated stays in the model's
|
||
/// cache, which the session shares with the preview's scratch copy, so OK then takes it from
|
||
/// there (the feature, its result or its error) instead of evaluating it again.
|
||
pub fn settle(app: &mut SolveApp, cmds: &[(String, Value)]) {
|
||
let key = job_key(cmds, app.session.revision, Colors::from_tokens(&Tokens::get()));
|
||
let Some((k, rx)) = app.preview.running.take_if(|(k, _)| *k == key) else { return };
|
||
loop {
|
||
match rx.recv() {
|
||
Ok(Msg::Partial(b)) => app.preview.apply(k, Ok(b)),
|
||
Ok(Msg::Done(job)) => {
|
||
app.preview.apply(k, job);
|
||
break;
|
||
}
|
||
Err(_) => break,
|
||
}
|
||
}
|
||
app.preview.running_motion_key = None;
|
||
app.preview.busy = false;
|
||
}
|
||
|
||
#[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 job = compute_staged(&scratch, &cmds, colors, &mut |b| {
|
||
let _ = tx.send(Msg::Partial(b));
|
||
});
|
||
let _ = tx.send(Msg::Done(job));
|
||
});
|
||
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.
|
||
/// Features that add or remove a tool body (extrude, revolve, primitives with join, cut or
|
||
/// intersect) show the tool itself: tinted for join, see-through red for cut. Other features
|
||
/// show their result in place of the bodies they change. When the feature fails (a boolean the
|
||
/// kernel can't do yet), the tool body is still shown, with the error.
|
||
pub fn compute(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Job {
|
||
compute_staged(s, cmds, colors, &mut |_| {})
|
||
}
|
||
|
||
/// [`compute`], handing a tool body's scene to `partial` as soon as it is built: the feature
|
||
/// itself (the boolean, which the preview runs for its error and OK finds in the cache) can
|
||
/// take far longer.
|
||
fn compute_staged(s: &Session, cmds: &[(String, Value)], colors: Colors, partial: &mut dyn FnMut(Built)) -> Job {
|
||
if cmds.iter().any(|(command, params)| {
|
||
matches!(command.as_str(), "sketch.redefine" | "sketch.mirror" | "sketch.pattern.rectangular" | "sketch.pattern.circular" | "sketch.offset")
|
||
|| command == "construct.plane.along_path"
|
||
|| params.get("command").and_then(Value::as_str) == Some("construct.plane.along_path")
|
||
}) {
|
||
let preview = s.preview_visible_world(cmds).map_err(|error| error.to_string())?;
|
||
return std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||
let mut built = build(&preview.before, &preview.after, colors);
|
||
built.bounds = state_bounds(&preview.after);
|
||
built
|
||
}))
|
||
.map_err(|_| "the preview failed".to_string());
|
||
}
|
||
// Joints and drives move occurrences: shown on the placed model.
|
||
if let Some(job) = crate::dialogs_assembly::world_preview(s, cmds, colors) {
|
||
return job;
|
||
}
|
||
let guard = |f: &dyn Fn() -> Built| std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(|_| "the preview failed".to_string());
|
||
match as_new_body(cmds) {
|
||
Some((alt, op)) => {
|
||
let tool = match s.preview(&alt) {
|
||
Ok(t) => t,
|
||
Err(e) => return Err(s.preview(cmds).err().map_or_else(|| e.to_string(), |r| r.to_string())),
|
||
};
|
||
let mut b = guard(&|| build_tool(&tool.before, &tool.after, colors, &op))?;
|
||
b.bounds = state_bounds(&tool.after);
|
||
partial(Built {
|
||
scene: b.scene.clone(),
|
||
sketches: Vec::new(),
|
||
replaced: b.replaced.clone(),
|
||
error: None,
|
||
bounds: b.bounds,
|
||
applied: None,
|
||
tapered: Vec::new(),
|
||
stopped: None,
|
||
});
|
||
b.error = s.preview(cmds).err().map(|e| e.to_string());
|
||
Ok(b)
|
||
}
|
||
None => {
|
||
let p = s.preview(cmds).map_err(|e| e.to_string())?;
|
||
let mut b = guard(&|| build(&p.before, &p.after, colors))?;
|
||
b.bounds = state_bounds(&p.after);
|
||
// The radius a previewed fillet used when it reduced the one asked for (a new
|
||
// fillet or an edited one: the feature the replies name).
|
||
let features = || p.replies.iter().filter_map(|r| r.get("feature").and_then(Value::as_u64));
|
||
b.applied = features().find_map(|id| p.after.fitted_range(id));
|
||
b.tapered = features().find_map(|id| p.after.tapered(id)).map(<[_]>::to_vec).unwrap_or_default();
|
||
Ok(b)
|
||
}
|
||
}
|
||
}
|
||
|
||
fn state_bounds(st: &ModelState) -> Aabb3 {
|
||
st.bodies.iter().fold(Aabb3::EMPTY, |b, x| b.union(&x.mesh().bounds()))
|
||
}
|
||
|
||
/// The tool bodies a join/cut/intersect adds: opaque and tinted for join, see-through for cut
|
||
/// (red) and intersect.
|
||
fn build_tool(before: &ModelState, after: &ModelState, c: Colors, op: &str) -> Built {
|
||
let mut sc = GpuScene::default();
|
||
for a in after.bodies.iter().filter(|a| before.body(&a.name).is_none()) {
|
||
let m = a.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)) {
|
||
match op {
|
||
"cut" => sc.xray_tri(p.to_f32(), n.to_f32(), c.removed),
|
||
"intersect" => sc.xray_tri(p.to_f32(), n.to_f32(), [c.added[0], c.added[1], c.added[2], c.removed[3]]),
|
||
_ => sc.tri(p.to_f32(), n.to_f32(), c.added),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
let edge = if op == "cut" { [c.removed[0], c.removed[1], c.removed[2], 255] } else { c.edge };
|
||
for (ei, e) in m.edges.iter().enumerate() {
|
||
if m.shaded_edge_visible(ei) {
|
||
for w in e.windows(2) {
|
||
sc.line(w[0].to_f32(), w[1].to_f32(), edge, 1.3, op != "join");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Built {
|
||
scene: sc,
|
||
sketches: Vec::new(),
|
||
replaced: Vec::new(),
|
||
error: None,
|
||
bounds: Aabb3::EMPTY,
|
||
applied: None,
|
||
tapered: Vec::new(),
|
||
stopped: None,
|
||
}
|
||
}
|
||
|
||
/// The same commands making a new body instead of joining, cutting or intersecting, and the
|
||
/// operation they had.
|
||
fn as_new_body(cmds: &[(String, Value)]) -> Option<(Vec<(String, Value)>, String)> {
|
||
let mut out = cmds.to_vec();
|
||
let mut changed: Option<String> = None;
|
||
for (command, p) in &mut out {
|
||
let inner = p.get("command").and_then(Value::as_str).map(str::to_string);
|
||
// A combine's operation is between bodies that already exist: it has no new-body form
|
||
// (and no tool body of its own to show).
|
||
if inner.as_deref().unwrap_or(command.as_str()) == "solid.combine" {
|
||
continue;
|
||
}
|
||
let p = if p.get("command").is_some() { p.get_mut("params")? } else { p };
|
||
if let Some(op) = p.get_mut("operation")
|
||
&& let Some(o) = op.as_str().filter(|o| *o != "new").map(str::to_string)
|
||
{
|
||
*op = Value::from("new");
|
||
changed = Some(o);
|
||
}
|
||
}
|
||
changed.map(|op| (out, op))
|
||
}
|
||
|
||
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 construct in &after.construct {
|
||
if before.construct.iter().any(|old| old == construct) {
|
||
continue;
|
||
}
|
||
if let solidcraft_engine::doc::construct::ConstructGeom::Plane(plane) = construct.geom {
|
||
let (o, x, y) = (plane.origin, plane.x * 10.0, plane.y * 10.0);
|
||
let quad = [o - x - y, o + x - y, o + x + y, o - x + y];
|
||
let color = [c.added[0], c.added[1], c.added[2], 64];
|
||
for triangle in [[quad[0], quad[1], quad[2]], [quad[0], quad[2], quad[3]]] {
|
||
for p in triangle {
|
||
sc.xray_tri(p.to_f32(), plane.normal().to_f32(), color);
|
||
}
|
||
}
|
||
for (a, b) in quad.iter().zip(quad.iter().cycle().skip(1)).take(4) {
|
||
sc.line(a.to_f32(), b.to_f32(), c.added, 1.3, true);
|
||
}
|
||
}
|
||
}
|
||
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> = match &old {
|
||
Some(bm) => new_faces(&am, nf, bm),
|
||
None => vec![true; nf],
|
||
};
|
||
// Material removed from a body (a hole, a pocket): its new faces also show through the
|
||
// body in the cut colour, so the cut is seen from any side.
|
||
let cut = old.as_ref().is_some_and(|bm| am.measure().volume < bm.measure().volume - 1e-6);
|
||
for (t, f) in am.triangles.iter().zip(&am.tri_face) {
|
||
let new = added.get(*f as usize).copied().unwrap_or(true);
|
||
let col = if new { 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);
|
||
if new && cut {
|
||
sc.xray_tri(p.to_f32(), n.to_f32(), c.removed);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
for (ei, e) in am.edges.iter().enumerate() {
|
||
if !am.shaded_edge_visible(ei) {
|
||
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());
|
||
}
|
||
}
|
||
let sketches = after
|
||
.sketches
|
||
.iter()
|
||
.filter_map(|sketch| {
|
||
let old = before.sketch(sketch.feature);
|
||
let old_curves = old.into_iter().flat_map(|s| s.sketch.curves.iter().map(|c| c.id.as_str())).collect::<std::collections::HashSet<_>>();
|
||
let old_points = old.into_iter().flat_map(|s| s.sketch.points.iter().map(|p| p.id.as_str())).collect::<std::collections::HashSet<_>>();
|
||
let indices = sketch
|
||
.sketch
|
||
.curves
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(index, curve)| {
|
||
let unchanged = old_curves.contains(curve.id.as_str());
|
||
if unchanged { None } else { Some(index) }
|
||
})
|
||
.collect::<Vec<_>>();
|
||
let points = sketch
|
||
.sketch
|
||
.points
|
||
.iter()
|
||
.enumerate()
|
||
.filter_map(|(i, p)| if old_points.contains(p.id.as_str()) { None } else { Some(i) })
|
||
.collect::<Vec<_>>();
|
||
if indices.is_empty() && points.is_empty() { None } else { Some(SketchPreview { sketch: sketch.clone(), curves: indices, points }) }
|
||
})
|
||
.collect();
|
||
Built { scene: sc, sketches, replaced, error: None, bounds: Aabb3::EMPTY, applied: None, tapered: Vec::new(), stopped: None }
|
||
}
|
||
|
||
/// Which of the `nf` faces of `m` are new surface, i.e. not on the old mesh? Each is decided by
|
||
/// a vote of points on it (triangle centroids spread over the face). One pass over `m`'s
|
||
/// triangles gathers the centroids, and the old mesh is indexed once, so the cost stays
|
||
/// near-linear however many faces there are (a modeled thread has thousands).
|
||
fn new_faces(m: &Mesh, nf: usize, old: &Mesh) -> Vec<bool> {
|
||
let mut cents: Vec<Vec<Vec3>> = vec![Vec::new(); nf];
|
||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||
if let (Some(face), Some([a, b, c])) = (cents.get_mut(*f as usize), m.tri(t)) {
|
||
face.push((a + b + c) / 3.0);
|
||
}
|
||
}
|
||
let index = SurfaceIndex::new(old);
|
||
cents
|
||
.iter()
|
||
.map(|cents| {
|
||
let Some(index) = &index else { return true };
|
||
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| !index.contains(***p)).count();
|
||
off * 3 > samples.len()
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The triangles of a mesh in a uniform grid, to ask whether a point lies on the surface (within
|
||
/// its tessellation tolerance) by looking only at the triangles near it.
|
||
struct SurfaceIndex {
|
||
tris: Vec<[Vec3; 3]>,
|
||
tol: f64,
|
||
lo: [f64; 3],
|
||
cell: [f64; 3],
|
||
res: usize,
|
||
cells: Vec<Vec<u32>>,
|
||
}
|
||
|
||
impl SurfaceIndex {
|
||
/// Most triangle-in-cell entries the grid may hold; a coarser grid is used beyond it.
|
||
const BUDGET: usize = 4_000_000;
|
||
|
||
/// None for a mesh with no surface.
|
||
fn new(m: &Mesh) -> Option<SurfaceIndex> {
|
||
let bb = m.bounds();
|
||
if bb.is_empty() {
|
||
return None;
|
||
}
|
||
let tol = (bb.diagonal() * 2e-3).max(1e-4);
|
||
let tris: Vec<[Vec3; 3]> = m.triangles.iter().filter_map(|t| m.tri(t)).collect();
|
||
let lo = [bb.min.x - tol, bb.min.y - tol, bb.min.z - tol];
|
||
let ext = [bb.max.x - bb.min.x + 2.0 * tol, bb.max.y - bb.min.y + 2.0 * tol, bb.max.z - bb.min.z + 2.0 * tol];
|
||
let mut res = ((tris.len() as f64).cbrt().round() as usize).clamp(1, 48);
|
||
loop {
|
||
let cell = [ext[0] / res as f64, ext[1] / res as f64, ext[2] / res as f64];
|
||
let mut index = SurfaceIndex { tris: Vec::new(), tol, lo, cell, res, cells: vec![Vec::new(); res * res * res] };
|
||
let mut entries = 0usize;
|
||
'fill: for (i, t) in tris.iter().enumerate() {
|
||
let (a, b) = index.range(t);
|
||
for x in a[0]..=b[0] {
|
||
for y in a[1]..=b[1] {
|
||
for z in a[2]..=b[2] {
|
||
entries += 1;
|
||
if entries > Self::BUDGET && res > 1 {
|
||
break 'fill;
|
||
}
|
||
if let Some(c) = index.cells.get_mut((x * res + y) * res + z) {
|
||
c.push(i as u32);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if entries <= Self::BUDGET || res == 1 {
|
||
index.tris = tris;
|
||
return Some(index);
|
||
}
|
||
res /= 2;
|
||
}
|
||
}
|
||
|
||
fn cell_of(&self, v: f64, axis: usize) -> usize {
|
||
let (lo, cell) = (self.lo.get(axis).copied().unwrap_or(0.0), self.cell.get(axis).copied().unwrap_or(1.0));
|
||
let k = ((v - lo) / cell).floor();
|
||
if k.is_finite() { (k.max(0.0) as usize).min(self.res - 1) } else { 0 }
|
||
}
|
||
|
||
/// The cells (inclusive corners) a triangle, grown by the tolerance, touches.
|
||
fn range(&self, t: &[Vec3; 3]) -> ([usize; 3], [usize; 3]) {
|
||
let (mut a, mut b) = ([0; 3], [0; 3]);
|
||
for axis in 0..3 {
|
||
let c = t.map(|p| [p.x, p.y, p.z][axis]);
|
||
let (lo, hi) = (c[0].min(c[1]).min(c[2]) - self.tol, c[0].max(c[1]).max(c[2]) + self.tol);
|
||
a[axis] = self.cell_of(lo, axis);
|
||
b[axis] = self.cell_of(hi, axis);
|
||
}
|
||
(a, b)
|
||
}
|
||
|
||
/// Does `p` lie on the surface?
|
||
fn contains(&self, p: Vec3) -> bool {
|
||
let (x, y, z) = (self.cell_of(p.x, 0), self.cell_of(p.y, 1), self.cell_of(p.z, 2));
|
||
let Some(cell) = self.cells.get((x * self.res + y) * self.res + z) else { return false };
|
||
cell.iter().filter_map(|i| self.tris.get(*i as usize)).any(|[a, b, c]| point_triangle_dist(p, *a, *b, *c) < self.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) }
|
||
}
|
||
|
||
/// The same computed sketch preview is drawn over both native GPU and CPU viewports.
|
||
pub fn paint_sketches(app: &SolveApp, painter: &egui::Painter, proj: &crate::viewport::Proj) {
|
||
if !app.dialog.as_ref().is_some_and(|d| matches!(d.kind, crate::dialogs::Kind::SketchModify(_)) && d.previews()) {
|
||
return;
|
||
}
|
||
let stroke = egui::Stroke::new(2.5, Tokens::get().preview_sketch);
|
||
let mut budget = solidcraft_engine::render::SamplingBudget::new(32_768, 65_536);
|
||
let mut curves = 0;
|
||
for preview in &app.preview.sketches {
|
||
let sketch = &preview.sketch;
|
||
for index in &preview.curves {
|
||
crate::tools::paint_curve(&sketch.sketch, *index, &sketch.plane, painter, proj, stroke, &mut budget);
|
||
curves += 1;
|
||
}
|
||
for index in &preview.points {
|
||
if let Some(point) = sketch.sketch.point(*index).and_then(|p| proj.to_screen(sketch.plane.to_world(p))) {
|
||
painter.circle_filled(point, 3.0, stroke.color);
|
||
}
|
||
}
|
||
}
|
||
crate::scenario::publish_count("sketch_preview_curves", curves as f64);
|
||
}
|
||
|
||
#[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] }
|
||
}
|
||
|
||
#[test]
|
||
fn correcting_inputs_clears_old_errors_and_discards_late_old_jobs() {
|
||
let mut pv = PreviewState {
|
||
shown_key: Some(1),
|
||
error: Some("unknown_angle".into()),
|
||
active: true,
|
||
replaced: vec!["Turn".into()],
|
||
key: 9,
|
||
..PreviewState::default()
|
||
};
|
||
let (tx, rx) = std::sync::mpsc::channel();
|
||
pv.running = Some((1, rx));
|
||
pv.poll(2, None);
|
||
assert!(pv.error.is_none(), "the corrected inputs can Apply while their preview computes");
|
||
assert!(pv.running.is_some());
|
||
assert!(pv.active);
|
||
assert_eq!(pv.replaced, ["Turn"]);
|
||
assert_eq!(pv.key, 9, "the last good geometry stays visible");
|
||
tx.send(Msg::Done(Err("unknown_angle".into()))).unwrap();
|
||
pv.poll(2, None);
|
||
assert!(pv.error.is_none(), "a late obsolete result cannot disable Apply again");
|
||
assert!(pv.running.is_none());
|
||
assert_eq!(pv.shown_key, Some(1));
|
||
assert_eq!(pv.key, 9);
|
||
|
||
let (tx, rx) = std::sync::mpsc::channel();
|
||
pv.running = Some((2, rx));
|
||
tx.send(Msg::Done(Err("current invalid expression".into()))).unwrap();
|
||
pv.poll(2, None);
|
||
assert_eq!(pv.error.as_deref(), Some("current invalid expression"));
|
||
assert_eq!(pv.shown_key, Some(2));
|
||
pv.poll(2, None);
|
||
assert_eq!(pv.error.as_deref(), Some("current invalid expression"), "current errors still prevent invalid Apply");
|
||
}
|
||
|
||
fn pose() -> Built {
|
||
Built {
|
||
scene: GpuScene::default(),
|
||
sketches: Vec::new(),
|
||
replaced: vec!["Lid".into()],
|
||
error: None,
|
||
bounds: Aabb3::EMPTY,
|
||
applied: None,
|
||
tapered: Vec::new(),
|
||
stopped: None,
|
||
}
|
||
}
|
||
|
||
/// The worker completes while the slider is still held or Play keeps advancing.
|
||
/// Intermediate successful poses must reach the viewport, and the final value
|
||
/// must still be computed rather than treating that intermediate pose as current.
|
||
#[test]
|
||
fn joint_motion_shows_completed_poses_while_inputs_keep_advancing() {
|
||
let mut pv = PreviewState::default();
|
||
for (running, wanted) in [(1, 2), (2, 3), (3, 4)] {
|
||
let (tx, rx) = std::sync::mpsc::channel();
|
||
pv.running = Some((running, rx));
|
||
pv.running_motion_key = Some(42);
|
||
pv.poll(wanted, Some(42));
|
||
assert!(pv.running.is_some());
|
||
tx.send(Msg::Done(Ok(pose()))).unwrap();
|
||
pv.poll(wanted, Some(42));
|
||
assert!(pv.active, "motion should be visible before input stops");
|
||
assert_eq!(pv.replaced, ["Lid"]);
|
||
assert_eq!(pv.key, running, "every completed pose reaches the scene");
|
||
assert_eq!(pv.shown_key, Some(running));
|
||
assert_ne!(pv.shown_key, Some(wanted), "the latest pose still needs a job");
|
||
}
|
||
let (tx, rx) = std::sync::mpsc::channel();
|
||
pv.running = Some((4, rx));
|
||
pv.running_motion_key = Some(42);
|
||
tx.send(Msg::Done(Ok(pose()))).unwrap();
|
||
pv.poll(4, Some(42));
|
||
assert_eq!(pv.shown_key, Some(4), "release catches up to the exact final pose");
|
||
assert!(pv.running.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn intermediate_motion_rejects_other_setups_and_obsolete_errors() {
|
||
for (context, job) in [
|
||
(None, Ok(pose())),
|
||
(Some(43), Ok(pose())),
|
||
(Some(42), Err("old invalid value".into())),
|
||
(Some(42), Ok(Built { error: Some("old tool preview error".into()), ..pose() })),
|
||
] {
|
||
let mut pv = PreviewState { running_motion_key: Some(42), ..PreviewState::default() };
|
||
let (tx, rx) = std::sync::mpsc::channel();
|
||
pv.running = Some((1, rx));
|
||
tx.send(Msg::Done(job)).unwrap();
|
||
pv.poll(2, context);
|
||
assert!(!pv.active);
|
||
assert!(pv.error.is_none(), "obsolete errors must not block Apply");
|
||
assert!(pv.running.is_none());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn motion_context_changes_only_when_the_joint_setup_changes() {
|
||
let commands = |angle| {
|
||
vec![
|
||
("joint.edit".into(), json!({"joint": 1, "flip": true})),
|
||
("joint.limits".into(), json!({"joint": 1, "min": "-135 deg", "max": "0 deg"})),
|
||
("joint.drive".into(), json!({"joint": 1, "values": [angle]})),
|
||
]
|
||
};
|
||
let key = motion_key(&commands("-30 deg"), 7, colors());
|
||
assert!(key.is_some());
|
||
assert_eq!(key, motion_key(&commands("-60 deg"), 7, colors()));
|
||
assert_ne!(key, motion_key(&commands("-60 deg"), 8, colors()));
|
||
let mut different = commands("-60 deg");
|
||
different[2].1["joint"] = json!(2);
|
||
assert_ne!(key, motion_key(&different, 7, colors()));
|
||
different = commands("-60 deg");
|
||
different[1].1["min"] = json!("-90 deg");
|
||
assert_ne!(key, motion_key(&different, 7, colors()));
|
||
different = commands("-60 deg");
|
||
different[0].1["flip"] = json!(false);
|
||
assert_ne!(key, motion_key(&different, 7, colors()));
|
||
let mut other_colors = colors();
|
||
other_colors.body = [9, 9, 9, 255];
|
||
assert_ne!(key, motion_key(&commands("-60 deg"), 7, other_colors));
|
||
assert!(motion_key(&[("solid.extrude".into(), json!({"distance": 10}))], 7, colors()).is_none());
|
||
for command in ["joint.create", "joint.drive"] {
|
||
assert_eq!(
|
||
motion_key(&[(command.into(), json!({"joint": 1, "values": [0]}))], 7, colors()),
|
||
motion_key(&[(command.into(), json!({"joint": 1, "values": [90]}))], 7, colors()),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Combine previews its result (cut, join or intersect between existing bodies) without an
|
||
/// error, so its dialog's OK is enabled. Before, the preview also tried the command as a new
|
||
/// body, which a combine has no form of, and always reported "operation must be join, cut or
|
||
/// intersect".
|
||
#[test]
|
||
fn combine_previews_its_result_without_an_error() {
|
||
let mut s = solidcraft_engine::Session::default();
|
||
s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 20, "body_name": "Cube"})).unwrap();
|
||
s.execute("solid.cylinder", &json!({"base": [10, 10, -5], "diameter": 6, "height": 30, "body_name": "Pin"})).unwrap();
|
||
for op in ["cut", "join", "intersect"] {
|
||
let cmd = ("solid.combine".to_string(), json!({"target": "Cube", "tools": ["Pin"], "operation": op, "keep_tools": false}));
|
||
let built = compute(&s, &[cmd], colors()).unwrap_or_else(|e| panic!("{op}: {e}"));
|
||
assert!(built.error.is_none(), "{op}: {:?}", built.error);
|
||
}
|
||
}
|
||
|
||
fn count(bytes: &[u8], stride: usize, col: [u8; 4]) -> usize {
|
||
bytes.chunks(stride).filter(|v| v.get(24..28) == Some(&col[..])).count()
|
||
}
|
||
|
||
#[test]
|
||
fn a_hole_shows_its_cut_through_the_body() {
|
||
let mut s = solidcraft_engine::Session::default();
|
||
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap();
|
||
let p = s.preview(&[("solid.hole".to_string(), json!({"position": [20, 15, 20], "diameter": 5, "depth": 10}))]).unwrap();
|
||
let b = build(&p.before, &p.after, colors());
|
||
// The hole's walls are drawn see-through in the cut colour (seen from any side).
|
||
assert!(count(&b.scene.xray, 28, colors().removed) > 0);
|
||
// A feature that only adds material shows no cut.
|
||
let p = s.preview(&[("solid.box".to_string(), json!({"length": 10, "width": 10, "height": 30, "operation": "join"}))]).unwrap();
|
||
assert_eq!(count(&build(&p.before, &p.after, colors()).scene.xray, 28, colors().removed), 0);
|
||
}
|
||
|
||
fn plate() -> Session {
|
||
let mut s = Session::default();
|
||
for (id, p) in [
|
||
("sketch.create", json!({"plane": "XY"})),
|
||
("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 30]})),
|
||
("sketch.finish", json!({})),
|
||
("solid.extrude", json!({"distance": 20})),
|
||
] {
|
||
s.execute(id, &p).unwrap();
|
||
}
|
||
s
|
||
}
|
||
|
||
/// The surface test the preview used to run per face against every triangle.
|
||
fn brute_force_on_surface(m: &Mesh, p: Vec3) -> bool {
|
||
let bb = m.bounds();
|
||
let tol = (bb.diagonal() * 2e-3).max(1e-4);
|
||
m.triangles.iter().filter_map(|t| m.tri(t)).any(|[a, b, c]| point_triangle_dist(p, a, b, c) < tol)
|
||
}
|
||
|
||
/// A flat sheet of `n × n` squares, two triangles and one face each, lifted by `z`.
|
||
fn sheet(n: usize, z: f64) -> Mesh {
|
||
let mut m = Mesh::default();
|
||
for i in 0..=n {
|
||
for j in 0..=n {
|
||
m.positions.push(Vec3::new(i as f64, j as f64, z));
|
||
m.normals.push(Vec3::Z);
|
||
}
|
||
}
|
||
for i in 0..n {
|
||
for j in 0..n {
|
||
let a = (i * (n + 1) + j) as u32;
|
||
let (b, c, d) = (a + 1, a + n as u32 + 1, a + n as u32 + 2);
|
||
m.triangles.extend([[a, c, d], [a, d, b]]);
|
||
m.tri_face.extend([(i * n + j) as u32; 2]);
|
||
}
|
||
}
|
||
m
|
||
}
|
||
|
||
/// The grid answers as a scan of every triangle does, inside, beside and far from the mesh.
|
||
#[test]
|
||
fn surface_index_matches_a_scan() {
|
||
let m = sheet(12, 0.0);
|
||
let index = SurfaceIndex::new(&m).unwrap();
|
||
for k in 0..2000 {
|
||
let f = k as f64;
|
||
let p = Vec3::new((f * 0.37).rem_euclid(15.0) - 1.5, (f * 0.61).rem_euclid(15.0) - 1.5, ((f * 0.13).rem_euclid(1.0) - 0.5) * 0.05);
|
||
assert_eq!(index.contains(p), brute_force_on_surface(&m, p), "{p:?}");
|
||
}
|
||
assert!(SurfaceIndex::new(&Mesh::default()).is_none());
|
||
}
|
||
|
||
/// Thousands of faces (a modeled thread's) are classified at once, not face by face against
|
||
/// every triangle: filleting beside a thread froze the app for minutes.
|
||
#[test]
|
||
fn many_faces_are_classified_quickly() {
|
||
let started = std::time::Instant::now();
|
||
let n = 120;
|
||
let (old, same, lifted) = (sheet(n, 0.0), sheet(n, 0.0), sheet(n, 5.0));
|
||
let nf = n * n;
|
||
assert!(new_faces(&same, nf, &old).iter().all(|new| !new), "faces on the old surface are not new");
|
||
assert!(new_faces(&lifted, nf, &old).iter().all(|new| *new), "faces off the old surface are new");
|
||
assert!(started.elapsed().as_secs() < 10, "took {:?}", started.elapsed());
|
||
}
|
||
|
||
/// 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, &[("solid.fillet".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 cut shows its tool see-through and replaces nothing.
|
||
#[test]
|
||
fn cut_preview_scene() {
|
||
let mut s = plate();
|
||
s.execute("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||
s.execute("sketch.circle.center", &json!({"center": [20, 15], "radius": 5})).unwrap();
|
||
s.execute("sketch.finish", &json!({})).unwrap();
|
||
let b = compute(&s, &[("solid.extrude".into(), json!({"distance": 20, "operation": "cut"}))], colors()).unwrap();
|
||
assert!(b.replaced.is_empty());
|
||
assert!(b.scene.tris.is_empty() && !b.scene.xray.is_empty());
|
||
assert!(b.error.is_none());
|
||
}
|
||
|
||
/// 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("sketch.create", &json!({"plane": "XY"})).unwrap();
|
||
s.execute("sketch.circle.center", &json!({"center": [0, 0], "radius": 5})).unwrap();
|
||
s.execute("sketch.finish", &json!({})).unwrap();
|
||
let b = compute(&s, &[("solid.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, &[("solid.extrude".into(), json!({"distance": "1 +"}))], colors()).is_err());
|
||
assert!(s.model.state().bodies.is_empty());
|
||
}
|
||
|
||
/// A block and a two-letter text to cut into it.
|
||
fn text_on_block() -> (Session, Vec<(String, Value)>) {
|
||
let mut s = Session::default();
|
||
for (id, p) in [
|
||
("sketch.create", json!({"plane": "XY"})),
|
||
("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [60, 20]})),
|
||
("sketch.finish", json!({})),
|
||
("solid.extrude", json!({"distance": 10})),
|
||
("sketch.create", json!({"plane": "XY"})),
|
||
("sketch.text", json!({"text": "AB", "at": [5, 5], "height": 10})),
|
||
("sketch.finish", json!({})),
|
||
] {
|
||
s.execute(id, &p).unwrap();
|
||
}
|
||
(s, vec![("solid.extrude".to_string(), json!({"distance": 2, "operation": "cut"}))])
|
||
}
|
||
|
||
/// A cut's tool is handed over as soon as it is built, before the cut itself has run; the
|
||
/// finished preview is the same tool, with the cut's error if any.
|
||
#[test]
|
||
fn a_cut_preview_shows_its_tool_before_the_boolean_runs() {
|
||
let (s, cmds) = text_on_block();
|
||
let mut partials = Vec::new();
|
||
let done = compute_staged(&s, &cmds, colors(), &mut |b| partials.push((b.scene.xray.len(), b.error.clone()))).unwrap();
|
||
assert_eq!(partials.len(), 1, "one early tool scene");
|
||
assert!(partials[0].0 > 0 && partials[0].1.is_none());
|
||
assert_eq!(done.scene.xray.len(), partials[0].0, "the cut tool, see-through");
|
||
assert!(done.error.is_none(), "{:?}", done.error);
|
||
// A feature with no tool body has nothing to show early.
|
||
let mut none = 0;
|
||
let fillet = vec![("solid.box".to_string(), json!({"length": 10, "width": 10, "height": 10}))];
|
||
compute_staged(&s, &fillet, colors(), &mut |_| none += 1).unwrap();
|
||
assert_eq!(none, 0);
|
||
}
|
||
|
||
/// OK while the preview still computes the same commands waits for it instead of computing
|
||
/// them again beside it, and then takes the feature from the cache the preview filled.
|
||
#[test]
|
||
fn ok_takes_the_result_of_the_preview_still_computing() {
|
||
let (s, cmds) = text_on_block();
|
||
let mut app = SolveApp::new(s, crate::Services::default());
|
||
let colors = Colors::from_tokens(&Tokens::get());
|
||
// A preview of other commands is left alone.
|
||
let other = vec![("solid.extrude".to_string(), json!({"distance": 3, "operation": "cut"}))];
|
||
spawn(&mut app.preview, job_key(&other, app.session.revision, colors), app.session.scratch(), other.clone(), colors);
|
||
settle(&mut app, &cmds);
|
||
assert!(app.preview.running.is_some(), "not the commands OK runs");
|
||
let key = job_key(&cmds, app.session.revision, colors);
|
||
spawn(&mut app.preview, key, app.session.scratch(), cmds.clone(), colors);
|
||
settle(&mut app, &cmds);
|
||
assert!(app.preview.running.is_none() && !app.preview.busy);
|
||
assert_eq!(app.preview.shown_key, Some(key));
|
||
assert!(app.preview.active && app.preview.error.is_none(), "{:?}", app.preview.error);
|
||
let hits = |app: &SolveApp| app.session.model.cache.lock().map(|c| c.hits).unwrap_or(0);
|
||
let before = hits(&app);
|
||
let (id, params) = cmds[0].clone();
|
||
app.run(&id, params).unwrap();
|
||
let _ = app.session.world_state();
|
||
assert!(hits(&app) > before, "the cut came from the preview's evaluation");
|
||
assert_eq!(app.session.world_state().bodies.len(), 1);
|
||
}
|
||
}
|