feat: drag a fillet's radius spline by its handles on the edge (#752)
A spline fillet's radius can now be dragged by handles on the edge, and the spline radius saves under format 17.
This commit is contained in:
1 parent
159f9913c1
commit
5267c96ab2
13 files changed
+414
-31
No files matched your search
@@ -302,6 +302,11 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
|
||||
}
|
||||
dir = None;
|
||||
}
|
||||
// A fillet with a radius spline is shaped by its handles along the edge; the arrow would sit
|
||||
// on one of them.
|
||||
if radius_spline_handles(app, ui, painter, proj, d) {
|
||||
dir = None;
|
||||
}
|
||||
let Some((label, kind, value)) = d.primary() else { return };
|
||||
let len = all_span.or_else(|| {
|
||||
if to_span.is_some() && value.trim().is_empty() { Some(0.0) } else { app.session.doc.eval(value, kind).ok().filter(|v| v.is_finite()) }
|
||||
@@ -404,6 +409,126 @@ fn drag_arrow(
|
||||
Some(ts)
|
||||
}
|
||||
|
||||
/// A fillet's radius spline on its first edge (a spline fillet, or the spline a taper gave it):
|
||||
/// the radius curve drawn into the body from the edge, and a handle per control radius at its
|
||||
/// Greville position, that far from the edge. Dragging a handle sets its control radius (snapped
|
||||
/// unless Alt/Ctrl); the first drag on a taper makes the fillet a spline fillet starting from the
|
||||
/// taper's spline. Whether the handles are shown.
|
||||
fn radius_spline_handles(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, d: &mut Dialog) -> bool {
|
||||
use serde_json::{Value, json};
|
||||
let t = Tokens::get();
|
||||
let Kind::Fillet { chamfer: false, .. } = d.kind else { return false };
|
||||
let st = app.session.world_state();
|
||||
let Some((mesh, index)) = d.inputs.first().and_then(|i| {
|
||||
i.items.iter().find_map(|x| match x {
|
||||
Sel::Edge { body, index, .. } => Some((st.body(body)?.mesh(), *index)),
|
||||
_ => None,
|
||||
})
|
||||
}) else {
|
||||
return false;
|
||||
};
|
||||
let Some((a, b)) = mesh.edges.get(index).and_then(|e| Some((*e.first()?, *e.last()?))) else { return false };
|
||||
let eval = |e: &str| app.session.doc.eval(e, ValueKind::Length).ok().filter(|v| v.is_finite());
|
||||
let style = d.extra.get("type").and_then(Value::as_str).unwrap_or("constant").to_string();
|
||||
// The spline, the end its positions start from and whether it is still the taper's.
|
||||
let (spline, start, taper) = if style == "spline" {
|
||||
let Kind::Fillet { radius, .. } = &d.kind else { return false };
|
||||
let knots: Vec<f64> = d.extra.get("knots").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_f64).collect();
|
||||
let exprs: Vec<String> =
|
||||
d.extra.get("radii").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect();
|
||||
let Some(radii) = std::iter::once(radius.as_str()).chain(exprs.iter().map(String::as_str)).map(eval).collect::<Option<Vec<f64>>>() else {
|
||||
return false;
|
||||
};
|
||||
let swap = d.extra.get("swap_ends").and_then(Value::as_bool).unwrap_or(false);
|
||||
(solidcraft_engine::doc::FilletStyle::spline(&knots, radii), if swap { b } else { a }, false)
|
||||
} else if style == "constant" && d.extra.get("fit").and_then(Value::as_str) == Some("taper") {
|
||||
// The taper of this edge: its point lies on the edge's chord.
|
||||
let on_edge = |p: Vec3| {
|
||||
let ab = b - a;
|
||||
let s = ((p - a).dot(ab) / ab.dot(ab).max(1e-300)).clamp(0.0, 1.0);
|
||||
(a + ab * s).dist(p) <= 1e-6 * (1.0 + ab.len())
|
||||
};
|
||||
let Some((_, tp)) = app.preview.tapered.iter().find(|(p, _)| on_edge(*p)) else { return false };
|
||||
(tp.spline.clone(), tp.start, true)
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
let other = if start.dist(a) <= start.dist(b) { b } else { a };
|
||||
let at = |u: f64| start + (other - start) * u;
|
||||
// Into the body, between the two faces (the edge is straight between planar faces).
|
||||
let Some(n) = edge_bisector(&mesh, index, at(0.5)).map(|v| -v) else { return false };
|
||||
let samples: Vec<Pos2> = (0..=48)
|
||||
.filter_map(|k| {
|
||||
let u = f64::from(k) / 48.0;
|
||||
proj.to_screen(at(u) + n * spline.eval(u))
|
||||
})
|
||||
.collect();
|
||||
painter.add(egui::Shape::line(samples, Stroke::new(1.5, t.manipulator)));
|
||||
let g = spline.greville();
|
||||
let tips: Vec<Option<Pos2>> = g.iter().zip(&spline.radii).map(|(u, r)| proj.to_screen(at(*u) + n * *r)).collect();
|
||||
painter.add(egui::Shape::dashed_line(&tips.iter().flatten().copied().collect::<Vec<_>>(), Stroke::new(1.0, t.manipulator), 4.0, 3.0));
|
||||
let half_height = app.cam.half_height();
|
||||
let last = g.len().saturating_sub(1);
|
||||
let mut changed = None;
|
||||
for (k, (u, ts)) in g.iter().zip(&tips).enumerate() {
|
||||
let Some(ts) = *ts else { continue };
|
||||
crate::scenario::publish_handle(&format!("fillet_r{k}"), ts);
|
||||
let r = ui.interact(egui::Rect::from_center_size(ts, vec2(16.0, 16.0)), egui::Id::new(("sc_fillet_r", k)), egui::Sense::drag());
|
||||
let hot = r.hovered() || r.dragged();
|
||||
let col = if hot { t.accent } else { t.manipulator };
|
||||
if let Some(fs) = proj.to_screen(at(*u)) {
|
||||
painter.line_segment([fs, ts], Stroke::new(1.0, col));
|
||||
}
|
||||
painter.circle(ts, if hot { 6.5 } else { 5.0 }, t.overlay, Stroke::new(2.0, col));
|
||||
if hot {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
}
|
||||
if r.dragged()
|
||||
&& let Some(p) = r.interact_pointer_pos()
|
||||
&& let Some(v) = along(proj, p, at(*u), n)
|
||||
{
|
||||
let free = ui.input(|i| i.modifiers.alt || i.modifiers.ctrl || i.modifiers.command);
|
||||
let step = snap_step(half_height);
|
||||
let v = if free { v } else { (v / step).round() * step };
|
||||
// Zero only at an end, where the round closes to a point.
|
||||
let floor = if k == 0 || k == last {
|
||||
0.0
|
||||
} else if free {
|
||||
1e-3
|
||||
} else {
|
||||
step
|
||||
};
|
||||
changed = Some((k, v.max(floor)));
|
||||
}
|
||||
}
|
||||
if let Some((k, v)) = changed {
|
||||
let mut radii = spline.radii.clone();
|
||||
if let Some(r) = radii.get_mut(k) {
|
||||
*r = v;
|
||||
}
|
||||
if taper {
|
||||
// The taper's spline becomes the fillet's own.
|
||||
d.extra.insert("type".into(), json!("spline"));
|
||||
d.extra.remove("fit");
|
||||
d.extra.insert("knots".into(), json!(spline.knots));
|
||||
d.extra.insert("swap_ends".into(), json!(start.dist(b) < start.dist(a)));
|
||||
d.extra.insert("radii".into(), json!(radii.iter().skip(1).map(|r| format_len(*r)).collect::<Vec<_>>()));
|
||||
if let Kind::Fillet { radius, .. } = &mut d.kind {
|
||||
*radius = format_len(radii.first().copied().unwrap_or(0.0));
|
||||
}
|
||||
} else if k == 0 {
|
||||
if let Kind::Fillet { radius, .. } = &mut d.kind {
|
||||
*radius = format_len(v);
|
||||
}
|
||||
} else if let Some(Value::Array(rs)) = d.extra.get_mut("radii")
|
||||
&& let Some(slot) = rs.get_mut(k - 1)
|
||||
{
|
||||
*slot = json!(format_len(v));
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The measured distance: a line between the closest points with its length.
|
||||
fn measure_line(app: &SolveApp, painter: &egui::Painter, proj: &Proj, r: &serde_json::Value) {
|
||||
let t = Tokens::get();
|
||||
|
||||
@@ -1700,7 +1700,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
&mut d.extra,
|
||||
"fi_type",
|
||||
"type",
|
||||
&[("Constant", "constant"), ("Chord Length", "chord"), ("Variable", "variable")],
|
||||
&[("Constant", "constant"), ("Chord Length", "chord"), ("Variable", "variable"), ("Spline", "spline")],
|
||||
);
|
||||
ui.end_row();
|
||||
}
|
||||
@@ -1709,7 +1709,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if !*chamfer {
|
||||
match rtype {
|
||||
1 => "Chord Length",
|
||||
2 => "Start Radius",
|
||||
2 | 3 => "Start Radius",
|
||||
_ => "Radius",
|
||||
}
|
||||
} else if *ctype == 0 {
|
||||
@@ -1804,6 +1804,21 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
if !*chamfer && rtype == 3 {
|
||||
// The radius along the edge as a B-spline: its control radii from the
|
||||
// start end, also dragged by their handles on the edge.
|
||||
row_label(ui, "Swap Ends");
|
||||
extra_check(ui, &mut d.extra, "swap_ends");
|
||||
ui.end_row();
|
||||
let mut radii = spline_radii(&mut d.extra, radius);
|
||||
let n = radii.len();
|
||||
for (k, r) in radii.iter_mut().enumerate() {
|
||||
row_label(ui, if k + 1 == n { "End Radius" } else { "Control Radius" });
|
||||
enter |= field(ui, r);
|
||||
ui.end_row();
|
||||
}
|
||||
d.extra.insert("radii".into(), json!(radii));
|
||||
}
|
||||
row_label(ui, "Tangent Chain");
|
||||
ui.checkbox(chain, "");
|
||||
ui.end_row();
|
||||
@@ -2391,6 +2406,29 @@ fn extra_field(ui: &mut egui::Ui, extra: &mut serde_json::Map<String, Value>, ke
|
||||
enter
|
||||
}
|
||||
|
||||
/// A spline fillet's control radii after the start radius, as expressions. A dialog without a
|
||||
/// spline yet gets a cubic one of two spans, all its control radii `radius`.
|
||||
pub(crate) fn spline_radii(extra: &mut serde_json::Map<String, Value>, radius: &str) -> Vec<String> {
|
||||
let radii: Vec<String> = extra
|
||||
.get("radii")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|r| match r {
|
||||
Value::String(x) => Some(x.clone()),
|
||||
Value::Number(n) => Some(n.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let knots = extra.get("knots").and_then(Value::as_array).map_or(0, Vec::len);
|
||||
// All control radii (the start radius and these) plus the degree (1…3) plus one.
|
||||
if !radii.is_empty() && (radii.len() + 3..=radii.len() + 5).contains(&knots) {
|
||||
return radii;
|
||||
}
|
||||
extra.insert("knots".into(), json!([0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0]));
|
||||
vec![radius.to_string(); 4]
|
||||
}
|
||||
|
||||
/// A variable fillet's middle radii as (position, radius expression).
|
||||
fn mid_points(extra: &serde_json::Map<String, Value>) -> Vec<(f64, String)> {
|
||||
extra
|
||||
@@ -2770,7 +2808,8 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
("solid.chamfer", p)
|
||||
} else {
|
||||
let mut p = json!({"edges": edges, "radius": radius});
|
||||
if d.extra.get("type").and_then(Value::as_str) == Some("variable") {
|
||||
let style = d.extra.get("type").and_then(Value::as_str);
|
||||
if matches!(style, Some("variable" | "spline")) {
|
||||
// The start radius goes at one end of the first edge (Swap Ends: the other).
|
||||
let ends = sels(d, 0).iter().find_map(|x| match x {
|
||||
Sel::Edge { body, index, .. } => {
|
||||
@@ -2783,7 +2822,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
p["start"] = pt(if swap { b } else { a });
|
||||
}
|
||||
let mids = mid_points(&d.extra);
|
||||
if !mids.is_empty() {
|
||||
if style == Some("variable") && !mids.is_empty() {
|
||||
p["mid"] = json!(mids.iter().map(|(t, r)| json!([t, r])).collect::<Vec<_>>());
|
||||
if d.extra.get("smooth").and_then(Value::as_bool).unwrap_or(false) {
|
||||
p["smooth"] = json!(true);
|
||||
@@ -3585,11 +3624,21 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
FilletStyle::Chord => {
|
||||
d.extra.insert("type".into(), json!("chord"));
|
||||
}
|
||||
FilletStyle::Variable { radius2, start, mid, smooth } => {
|
||||
d.extra.insert("type".into(), json!("variable"));
|
||||
d.extra.insert("radius2".into(), json!(radius2));
|
||||
d.extra.insert("mid".into(), json!(mid.iter().map(|(t, r)| json!([t, r])).collect::<Vec<_>>()));
|
||||
d.extra.insert("smooth".into(), json!(smooth));
|
||||
FilletStyle::Variable { start, .. } | FilletStyle::Spline { start, .. } => {
|
||||
match style {
|
||||
FilletStyle::Variable { radius2, mid, smooth, .. } => {
|
||||
d.extra.insert("type".into(), json!("variable"));
|
||||
d.extra.insert("radius2".into(), json!(radius2));
|
||||
d.extra.insert("mid".into(), json!(mid.iter().map(|(t, r)| json!([t, r])).collect::<Vec<_>>()));
|
||||
d.extra.insert("smooth".into(), json!(smooth));
|
||||
}
|
||||
FilletStyle::Spline { knots, radii, .. } => {
|
||||
d.extra.insert("type".into(), json!("spline"));
|
||||
d.extra.insert("knots".into(), json!(knots));
|
||||
d.extra.insert("radii".into(), json!(radii));
|
||||
}
|
||||
FilletStyle::Constant | FilletStyle::Chord => {}
|
||||
}
|
||||
// The start was the first edge's last point: ends swapped.
|
||||
let first_end = edges.first().and_then(|p| edge_sel(s, *p)).and_then(|x| match x {
|
||||
Sel::Edge { body, index, .. } => {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::doc::{FeatureKind, FilletStyle, kernel};
|
||||
|
||||
/// A plate whose top narrows from 20 to 2 mm along 40 mm, its top front edge rounded at 3 mm
|
||||
/// with Taper to Fit.
|
||||
fn fixture() -> Harness {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
|
||||
h.app.run("sketch.line", json!({"points": [[0, 0], [40, 0], [40, 2], [0, 20]], "closed": true})).unwrap();
|
||||
h.app.run("solid.extrude", json!({"distance": 15, "body_name": "Plate"})).unwrap();
|
||||
h.app.run("solid.fillet", json!({"edges": [[20, 0, 15]], "radius": 3, "fit": "taper", "name": "Round"})).unwrap();
|
||||
h.frames(4);
|
||||
h
|
||||
}
|
||||
|
||||
/// Open the fillet for editing and wait for its preview.
|
||||
fn open(h: &mut Harness) {
|
||||
h.step(&json!({"call": "ui.view", "params": {"view": "iso"}})).unwrap();
|
||||
h.step(&json!({"call": "ui.view", "params": {"view": "fit"}})).unwrap();
|
||||
h.step(&json!({"call": "ui.editFeature", "params": {"feature": "Round"}})).unwrap();
|
||||
let began = std::time::Instant::now();
|
||||
while (h.app.preview.busy || h.app.preview.tapered.is_empty()) && began.elapsed() < std::time::Duration::from_secs(60) {
|
||||
h.frame();
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
h.frames(4);
|
||||
}
|
||||
|
||||
fn volume(h: &Harness) -> f64 {
|
||||
kernel::measure(&h.app.session.world_state().body("Plate").unwrap().body).unwrap().volume
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_taper_handle_makes_the_fillet_a_spline() {
|
||||
let mut h = fixture();
|
||||
let tapered = volume(&h);
|
||||
open(&mut h);
|
||||
let (_, taper) = h.app.preview.tapered.first().cloned().expect("the preview's taper");
|
||||
let law =
|
||||
if taper.start.dist(solidcraft_engine::geom::Vec3::new(0.0, 0.0, 15.0)) < 1e-9 { taper.spline.clone() } else { taper.spline.reversed() };
|
||||
// A handle per control radius, each that radius into the body from the edge.
|
||||
let into = solidcraft_engine::geom::Vec3::new(0.0, 1.0, -1.0) * std::f64::consts::FRAC_1_SQRT_2;
|
||||
let at = |u: f64, r: f64| {
|
||||
let p = solidcraft_engine::geom::Vec3::new(40.0 * u, 0.0, 15.0) + into * r;
|
||||
json!([p.x, p.y, p.z])
|
||||
};
|
||||
let g = law.greville();
|
||||
assert_eq!(g.len(), law.radii.len());
|
||||
for (k, (u, r)) in g.iter().zip(&law.radii).enumerate() {
|
||||
let k = if taper.spline == law { k } else { g.len() - 1 - k };
|
||||
let shown = h.at(&json!({"handle": format!("fillet_r{k}")})).unwrap();
|
||||
let want = h.at(&json!({"world": at(*u, *r)})).unwrap();
|
||||
assert!((shown[0] - want[0]).hypot(shown[1] - want[1]) < 1.0, "handle {k}: {shown:?} vs {want:?}");
|
||||
}
|
||||
// The middle control radius pulled out to 4 mm (free drag): the fillet keeps the taper's
|
||||
// spline as its own with that radius changed.
|
||||
let mid = g.len() / 2;
|
||||
let k = if taper.spline == law { mid } else { g.len() - 1 - mid };
|
||||
h.step(&json!({"drag": [{"handle": format!("fillet_r{k}")}, {"world": at(g[mid], 4.0)}], "ctrl": true})).unwrap();
|
||||
let d = h.app.dialog.as_ref().unwrap();
|
||||
assert_eq!(d.extra.get("type").and_then(Value::as_str), Some("spline"));
|
||||
assert!(d.extra.get("fit").is_none());
|
||||
h.step(&json!({"widget": "OK"})).unwrap();
|
||||
let FeatureKind::Fillet { style: FilletStyle::Spline { knots, radii, .. }, radius, .. } = &h.app.session.doc.find_feature("Round").unwrap().kind
|
||||
else {
|
||||
panic!("a spline fillet")
|
||||
};
|
||||
assert_eq!(knots, &taper.spline.knots);
|
||||
let values: Vec<f64> =
|
||||
std::iter::once(radius).chain(radii).map(|e| h.app.session.doc.eval(e, solidcraft_engine::doc::expr::Kind::Length).unwrap()).collect();
|
||||
let moved: Vec<usize> = (0..values.len()).filter(|i| (values[*i] - taper.spline.radii[*i]).abs() > 1e-3).collect();
|
||||
assert_eq!(moved, [k], "{values:?} vs {:?}", taper.spline.radii);
|
||||
assert!((values[k] - 4.0).abs() < 0.05, "{values:?}");
|
||||
assert!(volume(&h) < tapered, "{} vs {tapered}", volume(&h));
|
||||
}
|
||||
@@ -70,6 +70,8 @@ mod extrude_edit_tests;
|
||||
mod file_load;
|
||||
#[cfg(test)]
|
||||
mod file_load_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod fillet_spline_handle_tests;
|
||||
pub mod palette;
|
||||
pub mod params_dialog;
|
||||
pub mod prefs;
|
||||
|
||||
@@ -44,6 +44,8 @@ pub struct Built {
|
||||
/// 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)>,
|
||||
}
|
||||
|
||||
type Job = Result<Built, String>;
|
||||
@@ -71,6 +73,8 @@ pub struct PreviewState {
|
||||
started: f64,
|
||||
/// The smallest and largest radius the previewed fillet used when it reduced the one asked for.
|
||||
pub applied: Option<(f64, f64)>,
|
||||
/// The radius splines the previewed fillet tapered its edges with.
|
||||
pub tapered: Vec<(Vec3, solidcraft_engine::kernel::Taper)>,
|
||||
}
|
||||
|
||||
impl PreviewState {
|
||||
@@ -79,6 +83,7 @@ impl PreviewState {
|
||||
self.shown_key = None;
|
||||
self.error = None;
|
||||
self.applied = None;
|
||||
self.tapered.clear();
|
||||
self.busy = false;
|
||||
if self.active || !self.replaced.is_empty() {
|
||||
self.active = false;
|
||||
@@ -105,11 +110,13 @@ impl PreviewState {
|
||||
self.active = true;
|
||||
self.error = b.error;
|
||||
self.applied = b.applied;
|
||||
self.tapered = b.tapered;
|
||||
self.set_scene(b.scene);
|
||||
}
|
||||
Err(e) => {
|
||||
self.error = Some(e);
|
||||
self.applied = None;
|
||||
self.tapered.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,7 +239,9 @@ pub fn compute(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Job {
|
||||
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).
|
||||
b.applied = p.replies.iter().filter_map(|r| r.get("feature").and_then(Value::as_u64)).find_map(|id| p.after.fitted_range(id));
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -269,7 +278,7 @@ fn build_tool(before: &ModelState, after: &ModelState, c: Colors, op: &str) -> B
|
||||
}
|
||||
}
|
||||
}
|
||||
Built { scene: sc, replaced: Vec::new(), error: None, bounds: Aabb3::EMPTY, applied: None }
|
||||
Built { scene: sc, replaced: Vec::new(), error: None, bounds: Aabb3::EMPTY, applied: None, tapered: Vec::new() }
|
||||
}
|
||||
|
||||
/// The same commands making a new body instead of joining, cutting or intersecting, and the
|
||||
@@ -379,7 +388,7 @@ pub fn build(before: &ModelState, after: &ModelState, c: Colors) -> Built {
|
||||
replaced.push(b.name.clone());
|
||||
}
|
||||
}
|
||||
Built { scene: sc, replaced, error: None, bounds: Aabb3::EMPTY, applied: None }
|
||||
Built { scene: sc, replaced, error: None, bounds: Aabb3::EMPTY, applied: None, tapered: Vec::new() }
|
||||
}
|
||||
|
||||
/// Which of the `nf` faces of `m` are new surface, i.e. not on the old mesh? Each is decided by
|
||||
|
||||
Reference in new issue
Block a user