From 57d01f84053f569afe40ec95288d854ea08159bb Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 7 Oct 2026 23:50:36 +0000 Subject: [PATCH] Kernel: revolved faces turned away from the angle wrap (booleans with revolved bodies work), cuts that leave pieces make separate bodies, trivial booleans (clear of / inside) by sampling, body mirrors with join; recipes: pattern source names, thread, holes at sketch points, mirror of bodies Oracle: 43/66. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- apps/solvecraft-cli/src/recipe.rs | 57 +++++++- crates/doc/src/document.rs | 6 + crates/doc/src/eval.rs | 52 +++++++- crates/engine/src/cmd/features.rs | 8 +- crates/kernel/src/body.rs | 125 +++++++++++++++++- crates/kernel/src/ops.rs | 51 +++++++ crates/kernel/src/tests.rs | 79 +++++++++++ crates/ui-egui/src/dialogs.rs | 2 +- .../src/transversal/divide_face/mod.rs | 8 ++ 9 files changed, 377 insertions(+), 11 deletions(-) diff --git a/apps/solvecraft-cli/src/recipe.rs b/apps/solvecraft-cli/src/recipe.rs index ec4cc1fa..d7f01dd5 100644 --- a/apps/solvecraft-cli/src/recipe.rs +++ b/apps/solvecraft-cli/src/recipe.rs @@ -212,7 +212,7 @@ pub fn to_script(recipe: &Value) -> Result { Some("rectangular_pattern") => { let d1 = f.get("direction1").cloned().unwrap_or(Value::Null); let d2 = f.get("direction2").cloned().unwrap_or(Value::Null); - let mut p = json!({"features": f.get("features"), "dir1": d1.get("axis"), "count1": d1.get("count"), "spacing1": d1.get("spacing"), "name": name}); + let mut p = json!({"features": feature_refs(f.get("features")), "dir1": d1.get("axis"), "count1": d1.get("count"), "spacing1": d1.get("spacing"), "name": name}); if !d2.is_null() { p["dir2"] = d2.get("axis").cloned().unwrap_or(Value::Null); p["count2"] = d2.get("count").cloned().unwrap_or(Value::Null); @@ -222,7 +222,7 @@ pub fn to_script(recipe: &Value) -> Result { } Some("circular_pattern") => { let axis = f.get("axis").map(|a| json!({"origin": a.get("origin").cloned().unwrap_or(json!([0, 0, 0])), "dir": a.get("dir")})).unwrap_or(Value::Null); - out.push(json!({"command": "PatternCircular", "params": {"features": f.get("features"), "axis": axis, "count": f.get("count"), "angle": f.get("total_angle").cloned().unwrap_or(json!(360)), "name": name}})); + out.push(json!({"command": "PatternCircular", "params": {"features": feature_refs(f.get("features")), "axis": axis, "count": f.get("count"), "angle": f.get("total_angle").cloned().unwrap_or(json!(360)), "name": name}})); } Some("loft") => { let sections: Vec = f @@ -255,11 +255,36 @@ pub fn to_script(recipe: &Value) -> Result { } out.push(json!({"command": "FusionDraftCommand", "params": {"faces": faces, "angle": angle, "neutral": neutral, "pull": n, "name": name}})); } + Some("thread") => { + let face = f.get("face").and_then(|x| x.get("point")).cloned().unwrap_or(Value::Null); + let mut p = json!({"face": face, "designation": f.get("designation"), "name": name}); + if f.get("full_length").and_then(Value::as_bool) == Some(false) + && let Some(l) = f.get("length") + { + p["length"] = l.clone(); + } + out.push(json!({"command": "FusionThreadCommand", "params": p})); + } Some("hole") => { let n = f.get("face").and_then(|fc| fc.get("plane_normal_outward").or(fc.get("normal_at_point_on_face"))).cloned(); let dir = n.and_then(|n| n.as_array().map(|a| a.iter().map(|x| -x.as_f64().unwrap_or(0.0)).collect::>())); let ty = f.get("hole_type").and_then(Value::as_str).unwrap_or("simple"); let mut p = json!({"position": f.get("position"), "diameter": f.get("diameter"), "type": ty, "name": name}); + // "sketch HolePoints points p1..p6" + if let Some(src) = f.get("positions_from").and_then(Value::as_str) { + let w: Vec<&str> = src.split_whitespace().collect(); + if let (Some(sk), Some(range)) = (w.iter().position(|x| *x == "sketch").and_then(|i| w.get(i + 1)), w.iter().position(|x| *x == "points").and_then(|i| w.get(i + 1))) { + let ids: Vec = match range.split_once("..") { + Some((a, b)) => { + let pre: String = a.chars().take_while(|c| !c.is_ascii_digit()).collect(); + let (lo, hi) = (a[pre.len()..].parse::().unwrap_or(1), b.trim_start_matches(pre.as_str()).parse::().unwrap_or(0)); + (lo..=hi).map(|k| format!("{pre}{k}")).collect() + } + None => range.split(',').map(str::to_string).collect(), + }; + p = json!({"sketch": sk, "points": ids, "diameter": f.get("diameter"), "type": ty, "name": name}); + } + } if let Some(d) = dir { p["direction"] = json!(d); } @@ -294,7 +319,11 @@ pub fn to_script(recipe: &Value) -> Result { Some(r @ ("XY" | "XZ" | "YZ")) => json!(r), _ => json!({"origin": pl.get("origin").cloned().unwrap_or(json!([0, 0, 0])), "normal": pl.get("normal")}), }; - out.push(json!({"command": "MirrorCommand", "params": {"features": f.get("features"), "plane": plane, "name": name}})); + let mut p = json!({"features": feature_refs(f.get("features")), "plane": plane, "name": name}); + if f.get("mirror_of").and_then(Value::as_str) == Some("bodies") { + p = json!({"bodies": f.get("bodies"), "combine": f.get("combine").cloned().unwrap_or(json!(false)), "plane": plane, "name": name}); + } + out.push(json!({"command": "MirrorCommand", "params": p})); } Some("fillet") => { out.push(json!({"command": "FusionFilletEdgesCommand", "params": {"edges": edge_points(f), "radius": f.get("radius"), "name": name}})) @@ -319,3 +348,25 @@ fn edge_points(f: &Value) -> Value { .collect(), ) } + +/// Fusion names a pattern's source features with an occurrence suffix ("Hole1 (1)"); the +/// features are the recipe's own names. +fn feature_refs(v: Option<&Value>) -> Value { + let list: Vec = v + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|x| match x.as_str() { + Some(s) => { + let t = s.trim(); + let base = match t.rfind(" (") { + Some(i) if t.ends_with(')') && t[i + 2..t.len() - 1].chars().all(|c| c.is_ascii_digit()) => &t[..i], + _ => t, + }; + json!(base) + } + None => x.clone(), + }) + .collect(); + json!(list) +} diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 153506f8..76a285c0 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -215,6 +215,12 @@ pub enum FeatureKind { Mirror { features: Vec, plane: PlaneRef, + /// Mirror these bodies instead of replaying features. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + bodies: Vec, + /// Join each mirrored body with its original. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + combine: bool, }, /// A drilled hole (simple, counterbore or countersink) at a point, into the material. Hole { diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 1286869a..af0b828e 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -458,6 +458,20 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec, op: Operation i += 1; keep }); + // A cut can leave a body in pieces: each piece becomes its own body ("Bar", "Bar (1)"). + let mut split: Vec = Vec::new(); + for mb in std::mem::take(&mut state.bodies) { + let pieces = mb.body.lumps().unwrap_or_else(|_| vec![mb.body.clone()]); + if pieces.len() < 2 { + split.push(mb); + continue; + } + for (k, p) in pieces.into_iter().enumerate() { + let name = if k == 0 { mb.name.clone() } else { format!("{} ({k})", mb.name) }; + split.push(ModelBody::new(name, p, mb.feature)); + } + } + state.bodies = split; } Operation::NewBody => {} } @@ -1157,7 +1171,13 @@ fn replay(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut return Err(DocError::Invalid("no features selected".into())); } for name in features { - let src = doc.find_feature(name).ok_or_else(|| DocError::Unknown(format!("feature `{name}`")))?; + // A sketch and its extrude often share a name; the feature that makes geometry wins. + let src = doc + .features + .iter() + .find(|x| &x.name == name && !matches!(x.kind, FeatureKind::Sketch { .. } | FeatureKind::ConstructionPlane { .. })) + .or_else(|| doc.find_feature(name)) + .ok_or_else(|| DocError::Unknown(format!("feature `{name}`")))?; let tools = feature_tools(vals, src, st)?; let (op, targets) = feature_op(src); let mut copies = Vec::new(); @@ -1273,9 +1293,35 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: let mats = pattern_transforms(vals, pattern)?; replay(doc, vals, f, st, features, &mats) } - FeatureKind::Mirror { features, plane } => { + FeatureKind::Mirror { features, plane, bodies, combine } => { let pl = doc.resolve_plane(vals, plane, 0)?; - replay(doc, vals, f, st, features, &[mirror_matrix(&pl)]) + if bodies.is_empty() { + return replay(doc, vals, f, st, features, &[mirror_matrix(&pl)]); + } + let m = mirror_matrix(&pl); + for n in bodies { + let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?; + let Some(mb) = st.bodies.get(i).cloned() else { continue }; + let mirrored = kernel::transform_matrix(&mb.body, m)?; + if *combine { + let joined = kernel::boolean(&mb.body, &mirrored, BoolOp::Union)? + .ok_or_else(|| DocError::Invalid("the mirror join produced nothing".into()))?; + for (k, p) in joined.lumps()?.into_iter().enumerate() { + if k == 0 { + if let Some(slot) = st.bodies.get_mut(i) { + *slot = ModelBody::new(mb.name.clone(), p, mb.feature); + } + } else { + let name = unique_body_name(st, &mb.name); + st.bodies.push(ModelBody::new(name, p, f.id)); + } + } + } else { + let name = unique_body_name(st, &mb.name); + st.bodies.push(ModelBody::new(name, mirrored, f.id)); + } + } + Ok(()) } FeatureKind::Fillet { edges, radius, body } | FeatureKind::Chamfer { edges, distance: radius, body } => { let r = val(vals, radius, Kind::Length)?; diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index cc893412..3ed8904e 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -42,7 +42,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SOLID", "CREATE") .icon("pattern_circ") .params("features: [names]; axis: X|Y|Z | {origin, dir}; count; angle? (default 360 deg)"), - CommandSpec::new("MirrorCommand", "Mirror", mirror).at("SOLID", "CREATE").icon("mirror").params("features: [names]; plane: XY|XZ|YZ | {origin, x_dir, y_dir}"), + CommandSpec::new("MirrorCommand", "Mirror", mirror).at("SOLID", "CREATE").icon("mirror").params("features: [names] | bodies: [names] (combine?: join with the original); plane: XY|XZ|YZ | {origin, x_dir, y_dir}"), CommandSpec::new("FusionHoleCommand", "Hole", hole) .at("SOLID", "CREATE") .icon("hole") @@ -552,7 +552,8 @@ fn pattern_circ(s: &mut Session, p: &Value) -> Result { fn mirror(s: &mut Session, p: &Value) -> Result { let cmd = "MirrorCommand"; - let features = source_features(s, p, cmd)?; + let bodies = string_list(p, "bodies"); + let features = if bodies.is_empty() { source_features(s, p, cmd)? } else { Vec::new() }; let plane = match p.get("plane") { Some(Value::String(n)) if solvecraft_geom::Plane::named(n).is_some() => solvecraft_doc::PlaneRef::Origin { name: n.to_ascii_uppercase() }, Some(o @ Value::Object(_)) => { @@ -566,7 +567,8 @@ fn mirror(s: &mut Session, p: &Value) -> Result { } _ => return Err(bad(cmd, "`plane` must be XY, XZ, YZ or {origin, normal}")), }; - add_feature(s, p, FeatureKind::Mirror { features, plane }) + let combine = bool_(p, "combine").unwrap_or(false); + add_feature(s, p, FeatureKind::Mirror { features, plane, bodies, combine }) } /// A plane reference from a parameter: an origin plane, a construction plane name, or an diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index ea7e3025..9a6b49e3 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -56,6 +56,75 @@ fn mesh_shells(solid: &Solid, tol: f64) -> Vec { .collect() } +/// Revolved surfaces are periodic in their angle; a face whose angles start at 0 is found at 0 +/// or 2π, which confuses the boolean's loop projection. Each revolved face's surface is turned +/// (same geometry) so the face sits around angle π, well away from the wrap. +fn rephase_revolved(solid: Solid) -> Solid { + use mt::{BoundedCurve, ParametricCurve, SearchNearestParameter}; + let r = guard("rephase", || { + let mut changed = false; + let mut shells = Vec::new(); + for sh in solid.boundaries() { + let mut faces = Vec::new(); + for f in sh.face_iter() { + let mt::Surface::RevolutedCurve(proc_) = f.surface() else { + faces.push(f.clone()); + continue; + }; + let surf = f.surface(); + // The face's middle angle. + let mut acc = Vec3::ZERO; + let mut n = 0.0f64; + for w in f.absolute_boundaries() { + for e in w.edge_iter() { + let c = e.oriented_curve(); + let (t0, t1) = c.range_tuple(); + for k in 0..4 { + acc += from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 4.0)); + n += 1.0; + } + } + } + let Some((_, v)) = surf.search_nearest_parameter(p3(acc / n.max(1.0)), None, 100) else { + faces.push(f.clone()); + continue; + }; + let phi = v.rem_euclid(std::f64::consts::TAU) - std::f64::consts::PI; + if phi.abs() < 0.3 { + faces.push(f.clone()); + continue; + } + let new_proc = proc_.map_ref(|rc| { + let (o, a) = (rc.origin(), rc.axis()); + let rot = mt::Matrix4::from_translation(mt::EuclideanSpace::to_vec(o)) + * mt::Matrix4::from_axis_angle(a, mt::Rad(phi)) + * mt::Matrix4::from_translation(-mt::EuclideanSpace::to_vec(o)); + mt::RevolutedCurve::by_revolution(mt::Transformed::transformed(rc.entity_curve(), rot), o, a) + }); + match mt::Face::try_new(f.absolute_boundaries().clone(), mt::Surface::RevolutedCurve(new_proc)) { + Ok(mut nf) => { + if !f.orientation() { + nf.invert(); + } + faces.push(nf); + changed = true; + } + Err(_) => faces.push(f.clone()), + } + } + shells.push(mt::Shell::from(faces)); + } + if !changed { + return Ok(None); + } + Ok(Solid::try_new(shells).ok()) + }); + match r { + Ok(Some(s)) => s, + _ => solid, + } +} + pub(crate) fn p3(v: Vec3) -> mt::Point3 { mt::Point3::new(v.x, v.y, v.z) } @@ -82,10 +151,11 @@ fn polyline_mid(pts: &[Vec3]) -> (Vec3, f64) { } impl Body { - pub(crate) fn new(mut solid: Solid) -> Result { + pub(crate) fn new(solid: Solid) -> Result { if solid.boundaries().is_empty() { return Err(KernelError::Failed("empty result".into())); } + let mut solid = rephase_revolved(solid); // Orient outward: a closed solid must have positive volume. let v = guard("orient", || Ok(mesh_shells(&solid, 1.0).iter().map(|s| s.to_polygon().volume()).sum::()))?; if v < 0.0 { @@ -328,3 +398,56 @@ impl Body { } } } + +impl Body { + /// The separate pieces of a body: each outer shell with the voids inside it. A body in one + /// piece comes back as itself. + pub fn lumps(&self) -> Result> { + if self.mesh.is_some() || self.solid.boundaries().len() < 2 { + return Ok(vec![self.clone()]); + } + let tol = (self.size() * 1e-3).max(1e-3); + guard("lumps", || { + let shells: Vec = self.solid.boundaries().clone(); + let meshes: Vec<(f64, Option)> = shells + .iter() + .map(|sh| { + let pm = sh.robust_triangulation(tol).to_polygon(); + let v = pm.volume(); + let mut m = Mesh::default(); + for p in pm.positions() { + m.positions.push(Vec3::new(p.x, p.y, p.z)); + } + for t in pm.faces().triangle_iter() { + let idx = |k: usize| t.get(k).map(|x| u32::try_from(x.pos).unwrap_or(0)).unwrap_or(0); + m.triangles.push([idx(0), idx(1), idx(2)]); + m.tri_face.push(0); + } + (v, Some(m)) + }) + .collect(); + let outer: Vec = (0..shells.len()).filter(|i| meshes.get(*i).is_some_and(|m| m.0 > 0.0)).collect(); + if outer.len() < 2 { + return Ok(vec![self.clone()]); + } + let mut groups: Vec> = outer.iter().filter_map(|i| shells.get(*i).cloned()).map(|s| vec![s]).collect(); + for (i, sh) in shells.iter().enumerate() { + if outer.contains(&i) { + continue; + } + // A void goes with the outer shell around one of its points. + let probe = sh.vertex_iter().next().map(|v| from_p3(v.point())); + let owner = outer.iter().position(|o| { + let (Some(p), Some((_, Some(m)))) = (probe, meshes.get(*o)) else { return false }; + m.contains(p) + }); + if let Some(k) = owner + && let Some(g) = groups.get_mut(k) + { + g.push(sh.clone()); + } + } + groups.into_iter().map(|g| Solid::try_new(g).map_err(|e| KernelError::Failed(format!("lump: {e}"))).and_then(Body::new)).collect() + }) + } +} diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 1dfb5533..48f9e829 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -69,6 +69,51 @@ fn mismatch(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh, result: &solve Some(bad as f64 / n.max(1) as f64) } +/// How one body sits relative to another, from sample points: (fraction of `b`'s samples +/// inside `a`, fraction of `a`'s samples inside `b`). +fn overlap(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh) -> Option<(f64, f64)> { + if a.triangles.len() + b.triangles.len() > 80_000 { + return None; + } + let frac = |inner: &solvecraft_geom::Mesh, outer: &solvecraft_geom::Mesh| -> Option { + let bx = inner.bounds(); + let s = bx.size(); + let (mut n, mut hit) = (0usize, 0usize); + for i in 1..=2000 { + let p = bx.min + Vec3::new(s.x * halton(i, 2), s.y * halton(i, 3), s.z * halton(i, 5)); + if !inner.contains(p) { + continue; + } + n += 1; + if outer.contains(p) { + hit += 1; + } + } + (n >= 50).then(|| hit as f64 / n as f64) + }; + Some((frac(b, a)?, frac(a, b)?)) +} + +/// Booleans that need no intersection: one body clear of the other, or inside it. These are +/// common with coincident curved faces (a hole drilled where one already is, a body joined +/// with itself), which the intersection can't handle. +fn trivial(a: &Body, b: &Body, op: BoolOp, ma: &solvecraft_geom::Mesh, mb: &solvecraft_geom::Mesh) -> Option> { + let (b_in_a, a_in_b) = overlap(ma, mb)?; + const NONE: f64 = 0.003; + const ALL: f64 = 0.997; + let disjoint = b_in_a < NONE && a_in_b < NONE; + match op { + BoolOp::Cut if disjoint => Some(Some(a.clone())), + BoolOp::Cut if a_in_b > ALL => Some(None), + BoolOp::Intersect if disjoint => Some(None), + BoolOp::Intersect if b_in_a > ALL => Some(Some(b.clone())), + BoolOp::Intersect if a_in_b > ALL => Some(Some(a.clone())), + BoolOp::Union if b_in_a > ALL => Some(Some(a.clone())), + BoolOp::Union if a_in_b > ALL => Some(Some(b.clone())), + _ => None, + } +} + thread_local! { /// Set while a boolean runs on bodies whose coincident faces were pushed apart (no /// second round of pushing). @@ -99,6 +144,12 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result> { }; bounds && consistent }; + // One body clear of or inside the other: no intersection needed. + if let (Some(ma), Some(mb)) = &meshes + && let Some(r) = trivial(a, b, op, ma, mb) + { + return Ok(r); + } let mut last = String::new(); // Coincident faces make the intersection fail after many retries; push them apart first. if !APART.with(|c| c.get()) && crate::coplanar::has_coincident_faces(a, b) { diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index fe35e6e0..b1abe9e9 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -777,3 +777,82 @@ fn shell_non_convex() { let v = measure(&s).unwrap().volume; assert!(rel(v, outer - cavity) < 1e-6, "{v} vs {}", outer - cavity); } + +#[test] +#[ignore] +fn debug_drilled_hole() { + let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 15.0)).unwrap(); + let r = 3.4; + let half = (118f64 / 2.0).to_radians(); + let tip = r / half.tan(); + let (top, bottom) = (0.6, -12.0); + let a = std::env::var("ANG").ok().and_then(|x| x.parse::().ok()).unwrap_or(0.0); + let rp = Plane::new(Vec3::new(20.0, 15.0, 15.0), Vec3::new(a.cos(), a.sin(), 0.0), Vec3::Z).unwrap(); + let tipv = if std::env::var_os("NOTIP").is_some() { 0.0 } else { tip }; + let pts = [Vec2::new(0.0, top), Vec2::new(r, top), Vec2::new(r, bottom), Vec2::new(0.0, bottom - tipv)]; + let region = Region2 { outer: Loop2::polygon(&pts), holes: vec![] }; + let tool = revolve(&rp, &[region], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU).unwrap().pop().unwrap(); + println!("tool faces {} vol {}", tool.face_count(), measure(&tool).unwrap().volume); + let r = guard("t", || { + let mut sb = tool.deep_copy(); + sb.not(); + Ok(truck_shapeops::and(&b.deep_copy(), &sb, 0.02).map(|s| s.face_iter().count())) + }); + println!("cut: {r:?}"); +} + +#[test] +#[ignore] +fn debug_face_param_areas() { + use truck_modeling::{BoundedCurve, ParametricCurve, SearchNearestParameter}; + let rp = Plane::new(Vec3::new(20.0, 15.0, 15.0), Vec3::X, Vec3::Z).unwrap(); + let pts = [Vec2::new(0.0, 0.6), Vec2::new(3.4, 0.6), Vec2::new(3.4, -12.0), Vec2::new(0.0, -12.0)]; + let region = Region2 { outer: Loop2::polygon(&pts), holes: vec![] }; + let tool = revolve(&rp, &[region], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU).unwrap().pop().unwrap(); + let ex = cylinder(Vec3::new(20.0, 15.0, 3.0), Vec3::Z, 3.4, 12.6).unwrap(); + for (name, b) in [("revolve", &tool), ("extrude", &ex)] { + for f in b.solid.face_iter() { + let s = f.surface(); + let mut poly: Vec<(f64, f64)> = Vec::new(); + for w in f.absolute_boundaries() { + for e in w.edge_iter() { + let c = e.oriented_curve(); + let (t0, t1) = c.range_tuple(); + for k in 0..8 { + let p = c.subs(t0 + (t1 - t0) * k as f64 / 8.0); + if let Some(uv) = s.search_nearest_parameter(p, poly.last().copied(), 100) { + poly.push(uv); + } + } + } + } + let n = poly.len(); + let area: f64 = (0..n) + .map(|i| { + let (a, b) = (poly[i], poly[(i + 1) % n]); + a.0 * b.1 - b.0 * a.1 + }) + .sum::() + / 2.0; + println!("{name}: orientation {} param area {area:.4} wires {}", f.orientation(), f.absolute_boundaries().len()); + } + } +} + +#[test] +#[ignore] +fn debug_revolved_tube_cut() { + let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 15.0)).unwrap(); + let rp = Plane::new(Vec3::new(20.0, 15.0, 15.0), Vec3::X, Vec3::Z).unwrap(); + for (name, inner) in [("tube", 1.0), ("solid", 0.0)] { + let pts = [Vec2::new(inner, 0.6), Vec2::new(3.4, 0.6), Vec2::new(3.4, -12.0), Vec2::new(inner, -12.0)]; + let region = Region2 { outer: Loop2::polygon(&pts), holes: vec![] }; + let tool = revolve(&rp, &[region], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU).unwrap().pop().unwrap(); + let r = guard("t", || { + let mut sb = tool.deep_copy(); + sb.not(); + Ok(truck_shapeops::and(&b.deep_copy(), &sb, 0.02).map(|s| s.face_iter().count())) + }); + println!("{name}: faces {} cut {r:?}", tool.face_count()); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 196b82ad..115fca15 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1556,7 +1556,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { + FeatureKind::Mirror { features, plane, .. } => { let mut d = start("MirrorCommand")?; let ids: Vec = features.iter().filter_map(|n| s.doc.find_feature(n).map(|f| f.id)).collect(); if let Some(inp) = d.inputs.get_mut(0) { diff --git a/vendor/truck-shapeops/src/transversal/divide_face/mod.rs b/vendor/truck-shapeops/src/transversal/divide_face/mod.rs index e72cef0e..bf021083 100644 --- a/vendor/truck-shapeops/src/transversal/divide_face/mod.rs +++ b/vendor/truck-shapeops/src/transversal/divide_face/mod.rs @@ -76,6 +76,14 @@ where } Some(()) })?; + if std::env::var_os("SHAPEOPS_TRACE").is_some() { + for c in pre_faces.iter().flatten() { + eprintln!(" positive loop: area {:.4} pts {} first {:?}", c.poly.area(), c.poly.len(), c.poly.front()); + } + for c in &negative_wires { + eprintln!(" negative loop: area {:.4} pts {} first {:?} status {:?}", c.poly.area(), c.poly.len(), c.poly.front(), c.wire.status()); + } + } negative_wires.into_iter().try_for_each(|chunk| { let pt = chunk.poly.front(); let op = crate::tr!(pre_faces.iter_mut().find(|face| face[0].poly.include(pt)), "loop not inside any face piece");