From 431b87c2b9721674e66acc42b16e2f96ce695591 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 7 Oct 2026 18:58:33 +0000 Subject: [PATCH] M1: hole feature (simple, drilled, counterbore, countersink), cone classification and apex counting Oracle: 19/29 parts match Fusion. Co-Authored-By: Claude Opus 5.5 --- apps/solvecraft-cli/src/recipe.rs | 21 +++++++++ crates/doc/src/document.rs | 44 ++++++++++++++++++ crates/doc/src/eval.rs | 77 ++++++++++++++++++++++++++++++- crates/engine/src/cmd/features.rs | 53 +++++++++++++++++++++ crates/geom/src/profile.rs | 5 ++ crates/kernel/src/build.rs | 47 ++++++++++++++++--- crates/kernel/src/tests.rs | 49 ++++++++++++++++++++ crates/kernel/src/topo.rs | 66 +++++++++++++++++++++++++- crates/ui-egui/src/icons.rs | 8 ++++ crates/ui-egui/src/timeline.rs | 1 + docs/oracle.md | 20 ++++---- docs/parity.md | 46 +++++++++--------- 12 files changed, 396 insertions(+), 41 deletions(-) diff --git a/apps/solvecraft-cli/src/recipe.rs b/apps/solvecraft-cli/src/recipe.rs index 4c4aa196..6be8e1a2 100644 --- a/apps/solvecraft-cli/src/recipe.rs +++ b/apps/solvecraft-cli/src/recipe.rs @@ -224,6 +224,27 @@ pub fn to_script(recipe: &Value) -> Result { 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}})); } + 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}); + if let Some(d) = dir { + p["direction"] = json!(d); + } + if f.get("extent").and_then(Value::as_str) != Some("through_all") { + p["depth"] = f.get("depth").cloned().unwrap_or(Value::Null); + if let Some(t) = f.get("tip_angle") { + p["tip_angle"] = t.clone(); + } + } + for (k, src) in [("cb_diameter", "counterbore_diameter"), ("cb_depth", "counterbore_depth"), ("cs_diameter", "countersink_diameter"), ("cs_angle", "countersink_angle")] { + if let Some(v) = f.get(src) { + p[k] = v.clone(); + } + } + out.push(json!({"command": "FusionHoleCommand", "params": p})); + } Some("construction_plane") => match f.get("method").and_then(Value::as_str) { Some("offset") => out.push(json!({"command": "ConstructionPlaneOffsetFromPlaneCommand", "params": {"base": f.get("base"), "offset": f.get("offset"), "name": name}})), _ => { diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 3bcde567..07d73d76 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -216,6 +216,18 @@ pub enum FeatureKind { features: Vec, plane: PlaneRef, }, + /// A drilled hole (simple, counterbore or countersink) at a point, into the material. + Hole { + position: Vec3, + /// Drilling direction (into the material). + direction: Vec3, + diameter: String, + /// Depth to the shoulder; `None` = through all. + #[serde(default, skip_serializing_if = "Option::is_none")] + depth: Option, + #[serde(default)] + hole: HoleKind, + }, /// A construction plane (sketch placement, split tool). ConstructionPlane { plane: PlaneRef, @@ -235,6 +247,26 @@ pub enum FeatureKind { }, } +/// Hole shapes. +#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum HoleKind { + /// Plain hole; blind holes get a drill point when `tip_angle` is set. + #[default] + Simple, + Drilled { + tip_angle: String, + }, + Counterbore { + cb_diameter: String, + cb_depth: String, + }, + Countersink { + cs_diameter: String, + cs_angle: String, + }, +} + /// Pattern layouts. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] @@ -298,6 +330,7 @@ impl FeatureKind { FeatureKind::Pattern { .. } => "CircularPatternFeature", FeatureKind::Mirror { .. } => "MirrorFeature", FeatureKind::ConstructionPlane { .. } => "ConstructionPlane", + FeatureKind::Hole { .. } => "HoleFeature", FeatureKind::Split { .. } => "SplitBodyFeature", FeatureKind::Move { .. } => "MoveFeature", } @@ -319,6 +352,7 @@ impl FeatureKind { FeatureKind::Pattern { .. } => "CircularPattern", FeatureKind::Mirror { .. } => "Mirror", FeatureKind::ConstructionPlane { .. } => "Plane", + FeatureKind::Hole { .. } => "Hole", FeatureKind::Split { .. } => "Split", FeatureKind::Move { .. } => "Move", } @@ -359,6 +393,16 @@ impl FeatureKind { PatternKind::Circular { count, angle, .. } => v.extend([count.as_str(), angle]), }, FeatureKind::Mirror { plane, .. } => plane_exprs(plane, &mut v), + FeatureKind::Hole { diameter, depth, hole, .. } => { + v.push(diameter); + v.extend(depth.iter().map(String::as_str)); + match hole { + HoleKind::Simple => {} + HoleKind::Drilled { tip_angle } => v.push(tip_angle), + HoleKind::Counterbore { cb_diameter, cb_depth } => v.extend([cb_diameter.as_str(), cb_depth]), + HoleKind::Countersink { cs_diameter, cs_angle } => v.extend([cs_diameter.as_str(), cs_angle]), + } + } FeatureKind::Move { translate, angle, .. } => { v.extend(translate.iter().map(String::as_str)); if let Some(a) = angle { diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index e903aaf4..a7c9eb25 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -10,7 +10,7 @@ use solvecraft_kernel::{self as kernel, Body, BoolOp}; use solvecraft_sketch::{CurveKind, Profile, SolveReport, find_profiles, solve}; use crate::expr::{self, Kind, Value}; -use crate::{AxisRef, DocError, Document, Feature, FeatureKind, Operation, ProfileSel, Result}; +use crate::{AxisRef, DocError, Document, Feature, FeatureKind, HoleKind, Operation, ProfileSel, Result}; /// A body in the evaluated model. #[derive(Clone, Debug)] @@ -604,6 +604,76 @@ fn feature_tools(vals: &BTreeMap, f: &Feature, st: &ModelState) - FeatureKind::Torus { center, major, minor, .. } => { Ok(vec![kernel::torus(*center, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?]) } + FeatureKind::Hole { position, direction, diameter, depth, hole } => { + let dir = direction.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?; + let r = val(vals, diameter, Kind::Length)? / 2.0; + if !(r > 1e-6) { + return Err(DocError::Invalid("hole diameter must be positive".into())); + } + let size = st.bodies.iter().map(|b| b.body.size()).fold(1.0, f64::max); + let top = (size * 0.01).max(0.1); + let bottom = match depth { + Some(d) => -val(vals, d, Kind::Length)?, + None => -(size * 2.0 + 1.0), + }; + if bottom >= 0.0 { + return Err(DocError::Invalid("hole depth must be positive".into())); + } + // Tools are cylinders and cones made by (tapered) extrudes along the axis, cut one + // after another, widest first. + let plane = Plane::from_normal(*position, -dir).ok_or_else(|| DocError::Invalid("hole axis".into()))?; + // Each tool's circle seam at its own angle, so seams don't line up between tools. + let seam = std::cell::Cell::new(0.0); + let disc = |rad: f64| { + seam.set(seam.get() + 0.613); + Region2 { outer: solvecraft_geom::Loop2::circle_from(Vec2::ZERO, rad, seam.get()), holes: vec![] } + }; + let cyl = |rad: f64, lo: f64, hi: f64| -> Result { + kernel::extrude(&plane, &[disc(rad)], lo, hi)?.pop().ok_or_else(|| DocError::Invalid("hole tool".into())) + }; + let mut tools = Vec::new(); + match hole { + HoleKind::Simple => tools.push(cyl(r, bottom, top)?), + HoleKind::Drilled { tip_angle } => { + let half = val(vals, tip_angle, Kind::Angle)? / 2.0; + if depth.is_some() && half > 1e-3 && half < std::f64::consts::FRAC_PI_2 { + // One revolved tool (cylinder with a drill point). + let tip = r / half.tan(); + let up = -dir; + let side = up.any_perp(); + let rp = Plane::new(*position, side, up).ok_or_else(|| DocError::Invalid("hole axis".into()))?; + let pts = [Vec2::new(0.0, top), Vec2::new(r, top), Vec2::new(r, bottom), Vec2::new(0.0, bottom - tip)]; + let region = Region2 { outer: solvecraft_geom::Loop2::polygon(&pts), holes: vec![] }; + tools.extend(kernel::revolve(&rp, &[region], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)?); + } else { + tools.push(cyl(r, bottom, top)?); + } + } + HoleKind::Counterbore { cb_diameter, cb_depth } => { + let (rc, dc) = (val(vals, cb_diameter, Kind::Length)? / 2.0, val(vals, cb_depth, Kind::Length)?); + if !(rc > r && dc > 0.0 && -dc > bottom) { + return Err(DocError::Invalid("the counterbore must be wider than the hole and shallower than it".into())); + } + // Wide and shallow first: the narrow hole then crosses the pocket floor. + tools.push(cyl(rc, -dc, top)?); + tools.push(cyl(r, bottom, top)?); + } + HoleKind::Countersink { cs_diameter, cs_angle } => { + let (rc, half) = (val(vals, cs_diameter, Kind::Length)? / 2.0, val(vals, cs_angle, Kind::Angle)? / 2.0); + if !(rc > r && half > 1e-3 && half < std::f64::consts::FRAC_PI_2) { + return Err(DocError::Invalid("the countersink must be wider than the hole".into())); + } + // Cone from above the face to just inside the hole radius, then the hole. + let r_top = rc + top * half.tan(); + let r_end = r * 0.9; + let len = (r_top - r_end) / half.tan(); + let top_plane = plane.offset(top); + tools.push(kernel::extrude_tapered(&top_plane, &disc(r_top), len, -1.0, -half)?); + tools.push(cyl(r, bottom, top)?); + } + } + Ok(tools) + } _ => Err(DocError::Invalid(format!("{} cannot be patterned or mirrored yet", f.name))), } } @@ -616,6 +686,7 @@ fn feature_op(f: &Feature) -> (Operation, Vec) { | FeatureKind::Cylinder { operation, .. } | FeatureKind::Sphere { operation, .. } | FeatureKind::Torus { operation, .. } => (*operation, Vec::new()), + FeatureKind::Hole { .. } => (Operation::Cut, Vec::new()), _ => (Operation::NewBody, Vec::new()), } } @@ -755,6 +826,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: let tools = feature_tools(vals, f, st)?; apply_op(st, f, tools, *operation, targets) } + FeatureKind::Hole { .. } => { + let tools = feature_tools(vals, f, st)?; + apply_op(st, f, tools, Operation::Cut, &[]) + } FeatureKind::ConstructionPlane { plane } => { doc.resolve_plane(vals, plane, 0)?; Ok(()) diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 3172f0b8..7ccf09cc 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -38,6 +38,11 @@ pub static COMMANDS: &[CommandSpec] = &[ .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("FusionHoleCommand", "Hole", hole) + .at("SOLID", "CREATE") + .icon("hole") + .key("H") + .params("position: [x,y,z] on a face; direction?: [x,y,z] (default: into the face); diameter; depth? (default through all); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?"), CommandSpec::new("ConstructionPlaneOffsetFromPlaneCommand", "Offset Plane", plane_offset) .at("SOLID", "CONSTRUCT") .icon("plane") @@ -482,3 +487,51 @@ fn split_body(s: &mut Session, p: &Value) -> Result { let plane = plane_param(s, p.get("plane"), cmd)?; add_feature(s, p, FeatureKind::Split { body, plane }) } + +fn hole(s: &mut Session, p: &Value) -> Result { + let cmd = "FusionHoleCommand"; + let position = p.get("position").and_then(vec3).ok_or_else(|| bad(cmd, "`position` must be [x, y, z]"))?; + let direction = match p.get("direction").and_then(vec3) { + Some(d) => d.normalized().ok_or_else(|| bad(cmd, "`direction` must be non-zero"))?, + None => { + // Into the planar face the position lies on. + let st = s.model.state(); + let mut found = None; + for b in &st.bodies { + let tol = (b.body.size() * 1e-3).max(1e-3); + for f in b.body.faces(tol).unwrap_or_default() { + if let Some(n) = f.plane_normal + && (position - f.centroid).dot(n).abs() < tol * 10.0 + { + found = Some(-n); + } + } + } + found.ok_or_else(|| bad(cmd, "no planar face at `position`; give `direction`"))? + } + }; + let diameter = req_expr(cmd, p, "diameter")?; + check_expr(s, &diameter, Kind::Length, cmd, "diameter")?; + let depth = expr(p, "depth"); + if let Some(d) = &depth { + check_expr(s, d, Kind::Length, cmd, "depth")?; + } + let get = |k: &str| -> Result { + let e = req_expr(cmd, p, k)?; + Ok(e) + }; + let kind = match str_(p, "type").unwrap_or("simple") { + "simple" => match expr(p, "tip_angle") { + Some(t) if depth.is_some() => solvecraft_doc::HoleKind::Drilled { tip_angle: t }, + _ => solvecraft_doc::HoleKind::Simple, + }, + "drilled" => solvecraft_doc::HoleKind::Drilled { tip_angle: expr(p, "tip_angle").unwrap_or_else(|| "118 deg".into()) }, + "counterbore" => solvecraft_doc::HoleKind::Counterbore { cb_diameter: get("cb_diameter")?, cb_depth: get("cb_depth")? }, + "countersink" => solvecraft_doc::HoleKind::Countersink { + cs_diameter: get("cs_diameter")?, + cs_angle: expr(p, "cs_angle").unwrap_or_else(|| "90 deg".into()), + }, + other => return Err(bad(cmd, format!("unknown hole type `{other}`"))), + }; + add_feature(s, p, FeatureKind::Hole { position, direction, diameter, depth, hole: kind }) +} diff --git a/crates/geom/src/profile.rs b/crates/geom/src/profile.rs index 0b42cf09..0e78e2e4 100644 --- a/crates/geom/src/profile.rs +++ b/crates/geom/src/profile.rs @@ -113,6 +113,11 @@ impl Loop2 { ], } } + /// Full circle starting (and split) at angle `start`. + pub fn circle_from(center: Vec2, radius: f64, start: f64) -> Loop2 { + use std::f64::consts::PI; + Loop2 { segs: vec![Seg2::Arc { center, radius, start, sweep: PI }, Seg2::Arc { center, radius, start: start + PI, sweep: PI }] } + } pub fn polygon(pts: &[Vec2]) -> Loop2 { let n = pts.len(); Loop2 { segs: (0..n).filter_map(|i| Some(Seg2::Line { a: *pts.get(i)?, b: *pts.get((i + 1) % n)? })).collect() } diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index fa50c0f0..ea249c54 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -338,24 +338,59 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2) let shift = plane.to_world(axis_origin); let local = Plane { origin: plane.origin - shift, ..*plane }; let plane = &local; + // Radial lines at the ends become planar caps (no degenerate disc centres). + let radial = |sg: &Seg2| matches!(*sg, Seg2::Line { a, b } if (b - a).normalized().is_some_and(|u| u.dot(d).abs() < 1e-9)); + let mut mid: &[Seg2] = &chain; + if mid.first().is_some_and(|f| radial(f) && on_axis(f.start())) { + mid = mid.get(1..).unwrap_or(&[]); + } + if mid.last().is_some_and(|l| radial(l) && on_axis(l.end())) { + mid = mid.get(..mid.len().saturating_sub(1)).unwrap_or(&[]); + } + if mid.is_empty() { + return Ok(None); + } + let apex_start = mid.first().is_some_and(|f| on_axis(f.start())); + let apex_end = mid.last().is_some_and(|l| on_axis(l.end())); guard("revolve", || { - let verts: Vec = chain + let verts: Vec = mid .iter() .map(|s| builder::vertex(p3(plane.to_world(s.start())))) - .chain(chain.last().map(|s| builder::vertex(p3(plane.to_world(s.end()))))) + .chain(mid.last().map(|s| builder::vertex(p3(plane.to_world(s.end()))))) .collect(); let mut edges: Vec = Vec::new(); - for (i, s) in chain.iter().enumerate() { + for (i, s) in mid.iter().enumerate() { let (Some(a), Some(b)) = (verts.get(i), verts.get(i + 1)) else { continue }; edges.push(match *s { Seg2::Line { .. } => builder::line(a, b), Seg2::Arc { .. } => builder::circle_arc(a, b, p3(plane.to_world(s.mid()))), }); } - let w: mt::Wire = edges.into(); + let mut w: mt::Wire = edges.into(); let axis = plane.dir_to_world(d); - let shell = builder::cone(&w, v3(axis), mt::Rad(std::f64::consts::TAU)); - let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("revolve: {e}")))?; + let mut shell = if apex_start || apex_end { + if !apex_start { + // builder::cone wants the apex first. + w = w.inverse(); + } + builder::cone(&w, v3(axis), mt::Rad(std::f64::consts::TAU)) + } else { + builder::rsweep(&w, p3(plane.to_world(axis_origin)), v3(axis), mt::Rad(std::f64::consts::TAU)) + }; + let sides = shell.len(); + for b in shell.extract_boundaries() { + let cap = builder::try_attach_plane(&[b]).map_err(|e| KernelError::Failed(format!("revolve cap: {e}")))?; + shell.push(cap); + } + let solid = Solid::try_new(vec![shell.clone()]).or_else(|_| { + // Caps may come out facing inwards: flip them. + let mut s2 = shell.clone(); + for f in s2.face_iter_mut().skip(sides) { + f.invert(); + } + Solid::try_new(vec![s2]) + }); + let solid = solid.map_err(|e| KernelError::Failed(format!("revolve: {e}")))?; let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid }; Body::new(solid).map(Some) }) diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 4cc936c7..0e4d680d 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -330,3 +330,52 @@ fn debug_sphere() { println!("{name} and {:?}", boolean(&b, &sp, BoolOp::Intersect).map(|o| o.map(|x| measure(&x).unwrap().volume))); } } + +#[test] +#[ignore] +fn debug_countersink() { + let b = box_solid(Vec3::ZERO, Vec3::new(60.0, 40.0, 15.0)).unwrap(); + for (name, pts) in [ + ("csink", vec![Vec2::new(0.0, 0.6), Vec2::new(6.6, 0.6), Vec2::new(3.0, -3.0), Vec2::new(3.0, -31.0), Vec2::new(0.0, -31.0)]), + ("csink-thru-top", vec![Vec2::new(0.0, 0.6), Vec2::new(6.6, 0.6), Vec2::new(3.0, -3.0), Vec2::new(3.0, -16.3), Vec2::new(0.0, -16.3)]), + ( + "cbore", + vec![Vec2::new(0.0, 0.6), Vec2::new(7.0, 0.6), Vec2::new(7.0, -5.0), Vec2::new(4.0, -5.0), Vec2::new(4.0, -31.0), Vec2::new(0.0, -31.0)], + ), + ] { + for (pn, pl) in [ + ("xz@top", Plane::new(Vec3::new(35.0, 20.0, 15.0), Vec3::X, Vec3::Z).unwrap()), + ("yz@top", Plane::new(Vec3::new(35.0, 20.0, 15.0), Vec3::Y, Vec3::Z).unwrap()), + ("diag", Plane::new(Vec3::new(35.0, 20.0, 15.0), Vec3::new(0.6, 0.8, 0.0), Vec3::Z).unwrap()), + ] { + let tool = revolve(&pl, &[Region2 { outer: Loop2::polygon(&pts), holes: vec![] }], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU) + .unwrap() + .pop() + .unwrap(); + println!( + "{name} {pn}: tool {} cut {:?}", + measure(&tool).unwrap().volume, + boolean(&b, &tool, BoolOp::Cut).map(|o| o.map(|x| measure(&x).unwrap().volume)) + ); + } + } +} + +#[test] +#[ignore] +fn debug_cbore_seq() { + let b = box_solid(Vec3::ZERO, Vec3::new(60.0, 40.0, 15.0)).unwrap(); + let pl = Plane::from_normal(Vec3::new(15.0, 20.0, 15.0), Vec3::Z).unwrap(); + for (s1, s2, r2top) in [(0.0, 0.0, 0.6), (0.613, 1.226, 0.6), (0.3, 0.9, 2.37)] { + let t1 = extrude(&pl, &[Region2 { outer: Loop2::circle_from(Vec2::ZERO, 4.0, s1), holes: vec![] }], -31.0, 0.6).unwrap().pop().unwrap(); + let c1 = boolean(&b, &t1, BoolOp::Cut).unwrap().unwrap(); + let t2 = extrude(&pl, &[Region2 { outer: Loop2::circle_from(Vec2::ZERO, 7.0, s2), holes: vec![] }], -5.0, r2top).unwrap().pop().unwrap(); + println!( + "seams {s1} {s2}: first {} second {:?}", + measure(&c1).unwrap().volume, + boolean(&c1, &t2, BoolOp::Cut).map(|o| o.map(|x| measure(&x).unwrap().volume)) + ); + let c2 = boolean(&b, &t2, BoolOp::Cut).unwrap().unwrap(); + println!(" swapped: {:?}", boolean(&c2, &t1, BoolOp::Cut).map(|o| o.map(|x| measure(&x).unwrap().volume))); + } +} diff --git a/crates/kernel/src/topo.rs b/crates/kernel/src/topo.rs index 473db891..f98389e9 100644 --- a/crates/kernel/src/topo.rs +++ b/crates/kernel/src/topo.rs @@ -26,6 +26,7 @@ enum Surf { Plane { n: Vec3, d: f64 }, Cylinder { axis: Vec3, p: Vec3, r: f64 }, Sphere { c: Vec3, r: f64 }, + Cone { axis: Vec3, apex: Vec3, cos: f64 }, Other(usize), } @@ -35,6 +36,7 @@ impl Surf { Surf::Plane { .. } => "plane", Surf::Cylinder { .. } => "cylinder", Surf::Sphere { .. } => "sphere", + Surf::Cone { .. } => "cone", Surf::Other(_) => "other", } } @@ -46,6 +48,9 @@ impl Surf { axis.dot(a2).abs() > 1.0 - 1e-6 && (r - r2).abs() < tol && (off - axis * off.dot(axis)).len() < tol } (Surf::Sphere { c, r }, Surf::Sphere { c: c2, r: r2 }) => c.dist(c2) < tol && (r - r2).abs() < tol, + (Surf::Cone { axis, apex, cos }, Surf::Cone { axis: a2, apex: p2, cos: c2 }) => { + axis.dot(a2) > 1.0 - 1e-5 && (cos - c2).abs() < 1e-4 && apex.dist(p2) < tol * 10.0 + } _ => false, } } @@ -105,6 +110,31 @@ fn classify(pts: &[Vec3], nrm: &[Vec3], tol: f64, id: usize) -> Surf { return Surf::Cylinder { axis, p: u * cu + v * cv, r }; } } + // Cone: normals at a constant angle to one axis; the apex lies on every tangent plane. + if let Some((axis, cos)) = cone_signature(&pts.iter().copied().zip(nrm.iter().copied()).collect::>()) + && cos.abs() > 1e-3 + && cos.abs() < 1.0 - 1e-3 + { + // Least squares: n_i · v = n_i · p_i. + let mut m = [[0.0f64; 3]; 3]; + let mut r = [0.0f64; 3]; + for (p, n) in pts.iter().zip(nrm) { + let nv = [n.x, n.y, n.z]; + let k = n.dot(*p); + for i in 0..3 { + for j in 0..3 { + m[i][j] += nv[i] * nv[j]; + } + r[i] += nv[i] * k; + } + } + if let Some(v) = solve3(m, r) { + let apex = Vec3::new(v[0], v[1], v[2]); + if pts.iter().zip(nrm).all(|(p, n)| (*p - apex).dot(*n).abs() < tol.max(1e-6) * 10.0) { + return Surf::Cone { axis, apex, cos }; + } + } + } // Sphere: points equidistant from the point where normals meet. let c_est = { let mut acc = Vec3::ZERO; @@ -184,6 +214,27 @@ fn same_surface(surfs: &[Surf], verts: &[Vec<(Vec3, Vec3)>], edge: Option<&Vec Option<[f64; 3]> { + let det = |a: [[f64; 3]; 3]| { + a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0]) + + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]) + }; + let d = det(m); + if d.abs() < 1e-12 { + return None; + } + let mut out = [0.0; 3]; + for (k, o) in out.iter_mut().enumerate() { + let mut mk = m; + for (row, rv) in mk.iter_mut().zip(r) { + row[k] = rv; + } + *o = det(mk) / d; + } + Some(out) +} + fn find(p: &mut [usize], mut i: usize) -> usize { while let Some(&q) = p.get(i) { if q == i { @@ -365,5 +416,18 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result { let real_vertices = deg.keys().filter(|v| !merge_vertex.contains(v)).count(); let closed_loops = edge_groups.values().filter(|r| !**r).count(); let _ = vpos; - Ok(TopoCounts { faces: groups.len(), edges: edge_groups.len(), vertices: real_vertices + closed_loops, face_types }) + // A cone that reaches its apex has a degenerate edge and a vertex there (as other kernels + // count them). + let mut apexes = 0; + for (root, f) in &groups { + if let Some(Surf::Cone { apex, .. }) = surfs.get(*f) { + let reaches = (0..nf) + .filter(|g| find(&mut fp.clone(), *g) == *root) + .any(|g| verts.get(g).is_some_and(|v| v.iter().any(|(p, _)| p.dist(*apex) < tol * 10.0))); + if reaches { + apexes += 1; + } + } + } + Ok(TopoCounts { faces: groups.len(), edges: edge_groups.len() + apexes, vertices: real_vertices + closed_loops + apexes, face_types }) } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 809cbf32..e124eda6 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -147,6 +147,14 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce ); } } + "hole" => { + pen.poly(&[(3.0, 8.0), (21.0, 8.0), (21.0, 20.0), (3.0, 20.0)], pen.fill, ink); + let pts: Vec = + (0..32).map(|i| i as f32 / 32.0 * std::f32::consts::TAU).map(|t| pen.at(12.0 + 5.0 * t.cos(), 8.0 + 2.0 * t.sin())).collect(); + p.add(Shape::convex_polygon(pts, Color32::WHITE, Stroke::new(pen.w, a))); + pen.line(&[(7.0, 8.0), (7.0, 16.0)], a); + pen.line(&[(17.0, 8.0), (17.0, 16.0)], a); + } "split" => { pen.poly(&[(3.0, 6.0), (21.0, 6.0), (21.0, 18.0), (3.0, 18.0)], pen.fill, ink); pen.line(&[(8.0, 2.0), (16.0, 22.0)], a); diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index c93c83ad..4d55383a 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -23,6 +23,7 @@ fn icon_of(k: &FeatureKind) -> &'static str { FeatureKind::Pattern { .. } => "pattern_circ", FeatureKind::Mirror { .. } => "mirror", FeatureKind::ConstructionPlane { .. } => "plane", + FeatureKind::Hole { .. } => "hole", FeatureKind::Split { .. } => "split", FeatureKind::Move { .. } => "move", } diff --git a/docs/oracle.md b/docs/oracle.md index 1afcec2b..1d2477d8 100644 --- a/docs/oracle.md +++ b/docs/oracle.md @@ -2,7 +2,7 @@ Generated by `cargo xtask oracle`. Each case is a part built in Fusion (recipe and measurements kept locally in `plan/fusion/oracle/`, not committed). SolveCraft replays the recipe through its command engine and compares body count, volume and area (relative tolerance 1e-3) and face/edge/vertex counts (exact). -**12 / 29 cases pass** (10 need features not implemented yet). +**19 / 29 cases pass** (4 need features not implemented yet). | Case | Result | Notes | |---|---|---| @@ -13,25 +13,25 @@ Generated by `cargo xtask oracle`. Each case is a part built in Fusion (recipe a | 05-taper-extrude | pass | | | 06-symmetric-extrude | pass | | | 07-fillet-all-edges | FAIL | step 8 (FusionFilletEdgesCommand): not supported yet: the faces at the edge ends must be planar | -| 08-chamfer | FAIL | step 8 (FusionChamferCommand): not supported yet: the faces at the edge ends must be perpendicular to the edge | +| 08-chamfer | pass | | | 09-shell | not yet | recipe: feature op `shell` is not supported yet | -| 10-rect-pattern-holes | not yet | recipe: feature op `rectangular_pattern` is not supported yet | -| 11-circular-pattern-flange | not yet | recipe: feature op `circular_pattern` is not supported yet | -| 12-mirror-feature | not yet | recipe: feature op `mirror` is not supported yet | +| 10-rect-pattern-holes | pass | | +| 11-circular-pattern-flange | pass | | +| 12-mirror-feature | pass | | | 13-combine-join | FAIL | step 12 (FusionCombineCommand): boolean Union: kernel internal error: boolean: This shell is not oriented and closed. | | 14-combine-cut | FAIL | step 13 (FusionCombineCommand): boolean Cut: the operation failed: no result | | 15-combine-intersect | FAIL | step 13 (FusionCombineCommand): boolean Intersect: the operation failed: no result | | 16-sweep | not yet | recipe: feature op `sweep` is not supported yet | -| 17-loft | not yet | recipe: feature op `construction_plane` is not supported yet | -| 18-hole-features | not yet | recipe: feature op `hole` is not supported yet | +| 17-loft | not yet | recipe: feature op `loft` is not supported yet | +| 18-hole-features | pass | | | 19-l-bracket-constrained | pass | | | 20-slot-constrained | pass | | | 21-draft | not yet | recipe: feature op `draft` is not supported yet | -| 22-split-body | not yet | recipe: feature op `construction_plane` is not supported yet | -| 23-bracket-multifeature | not yet | recipe: feature op `construction_plane` is not supported yet | +| 22-split-body | pass | | +| 23-bracket-multifeature | FAIL | step 14 (Extrude): boolean Union: the operation failed: no result | | 24-user-parameters | pass | | | 25-sketch-triangle | pass | | | 26-sketch-belt | pass | | | 27-sketch-trapezoid | pass | | | 28-blind-pocket-fillets | FAIL | step 15 (FusionFilletEdgesCommand): not supported yet: the blend is larger than the neighbouring faces | -| 29-two-sided-and-partial-revolve | FAIL | step 14 (Revolve): boolean Cut: the operation failed: no result | +| 29-two-sided-and-partial-revolve | pass | | diff --git a/docs/parity.md b/docs/parity.md index 95b7bba5..46fdaee3 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,15 +2,15 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the toolbar commands of Fusion's Design workspace, ids and names only) and the live command registry (`solvecraft-cli commands`). A command counts as live when SolveCraft registers the same command id. This measures breadth, not depth; see ROADMAP.md and docs/oracle.md for depth. -**SOLID + SKETCH: 46 / 291 commands live (16%).** All Design tabs: 95 / 1175 (8%). Registered commands: 67. +**SOLID + SKETCH: 53 / 291 commands live (18%).** All Design tabs: 129 / 1175 (11%). Registered commands: 74. | Tab | Panel | Live | Total | % | |---|---|---:|---:|---:| -| SOLID | CREATE | 7 | 35 | 20% | -| SOLID | MODIFY | 7 | 32 | 22% | +| SOLID | CREATE | 11 | 35 | 31% | +| SOLID | MODIFY | 8 | 32 | 25% | | SOLID | ASSEMBLE | 0 | 21 | 0% | | SOLID | CONFIGURE | 0 | 3 | 0% | -| SOLID | CONSTRUCT | 0 | 20 | 0% | +| SOLID | CONSTRUCT | 2 | 20 | 10% | | SOLID | INSPECT | 1 | 25 | 4% | | SOLID | INSERT | 0 | 11 | 0% | | SOLID | SELECT | 0 | 15 | 0% | @@ -24,11 +24,11 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool | SKETCH | ASSEMBLE | 0 | 1 | 0% | | SKETCH | SELECT | 0 | 15 | 0% | | SKETCH | FINISH SKETCH | 1 | 2 | 50% | -| SURFACE | CREATE | 1 | 25 | 4% | -| SURFACE | MODIFY | 6 | 26 | 23% | +| SURFACE | CREATE | 4 | 25 | 16% | +| SURFACE | MODIFY | 7 | 26 | 27% | | SURFACE | ASSEMBLE | 0 | 20 | 0% | | SURFACE | CONFIGURE | 0 | 3 | 0% | -| SURFACE | CONSTRUCT | 0 | 20 | 0% | +| SURFACE | CONSTRUCT | 2 | 20 | 10% | | SURFACE | INSPECT | 1 | 25 | 4% | | SURFACE | INSERT | 0 | 11 | 0% | | SURFACE | SELECT | 0 | 15 | 0% | @@ -38,7 +38,7 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool | MESH | MODIFY | 4 | 30 | 13% | | MESH | ASSEMBLE | 0 | 21 | 0% | | MESH | CONFIGURE | 0 | 3 | 0% | -| MESH | CONSTRUCT | 0 | 20 | 0% | +| MESH | CONSTRUCT | 2 | 20 | 10% | | MESH | INSPECT | 1 | 25 | 4% | | MESH | INSERT | 0 | 10 | 0% | | MESH | SELECT | 0 | 10 | 0% | @@ -48,40 +48,40 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool | FORM | MODIFY | 2 | 31 | 6% | | FORM | SYMMETRY | 0 | 6 | 0% | | FORM | UTILITIES | 0 | 5 | 0% | -| FORM | CONSTRUCT | 0 | 20 | 0% | +| FORM | CONSTRUCT | 2 | 20 | 10% | | FORM | INSPECT | 1 | 25 | 4% | | FORM | INSERT | 0 | 13 | 0% | | FORM | ASSEMBLE | 0 | 1 | 0% | | FORM | SELECT | 0 | 15 | 0% | | FORM | FINISH FORM | 0 | 1 | 0% | -| SHEET METAL | CREATE | 2 | 19 | 11% | +| SHEET METAL | CREATE | 6 | 19 | 32% | | SHEET METAL | MODIFY | 6 | 21 | 29% | | SHEET METAL | ASSEMBLE | 0 | 20 | 0% | | SHEET METAL | CONFIGURE | 0 | 3 | 0% | -| SHEET METAL | CONSTRUCT | 0 | 20 | 0% | +| SHEET METAL | CONSTRUCT | 2 | 20 | 10% | | SHEET METAL | INSPECT | 1 | 25 | 4% | | SHEET METAL | INSERT | 0 | 11 | 0% | | SHEET METAL | SELECT | 0 | 15 | 0% | | SHEET METAL | REFOLD | 0 | 1 | 0% | | SHEET METAL | POSITION | 0 | 2 | 0% | | PLASTIC | SETUP | 0 | 2 | 0% | -| PLASTIC | CREATE | 7 | 35 | 20% | -| PLASTIC | MODIFY | 7 | 32 | 22% | +| PLASTIC | CREATE | 11 | 35 | 31% | +| PLASTIC | MODIFY | 8 | 32 | 25% | | PLASTIC | ASSEMBLE | 0 | 20 | 0% | | PLASTIC | CONFIGURE | 0 | 3 | 0% | -| PLASTIC | CONSTRUCT | 0 | 20 | 0% | +| PLASTIC | CONSTRUCT | 2 | 20 | 10% | | PLASTIC | INSPECT | 1 | 26 | 4% | | PLASTIC | INSERT | 0 | 11 | 0% | | PLASTIC | SELECT | 0 | 15 | 0% | | PLASTIC | POSITION | 0 | 2 | 0% | | ASSEMBLY | INSERT | 0 | 8 | 0% | -| ASSEMBLY | CREATE | 1 | 8 | 12% | +| ASSEMBLY | CREATE | 3 | 8 | 38% | | ASSEMBLY | RELATIONSHIPS | 0 | 11 | 0% | | ASSEMBLY | MOTION | 0 | 3 | 0% | | ASSEMBLY | MODIFY | 4 | 11 | 36% | | ASSEMBLY | CONTACT | 0 | 2 | 0% | | ASSEMBLY | CONFIGURE | 0 | 3 | 0% | -| ASSEMBLY | CONSTRUCT | 0 | 20 | 0% | +| ASSEMBLY | CONSTRUCT | 2 | 20 | 10% | | ASSEMBLY | INSPECT | 1 | 25 | 4% | | ASSEMBLY | SELECT | 0 | 15 | 0% | | ASSEMBLY | POSITION | 0 | 2 | 0% | @@ -105,17 +105,24 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool - SOLID › CREATE › Create Sketch (`SketchCreate`) - SOLID › CREATE › Extrude (`Extrude`) - SOLID › CREATE › Revolve (`Revolve`) +- SOLID › CREATE › Hole (`FusionHoleCommand`) - SOLID › CREATE › Box (`PrimitiveBox`) - SOLID › CREATE › Cylinder (`PrimitiveCylinder`) - SOLID › CREATE › Sphere (`PrimitiveSphere`) - SOLID › CREATE › Torus (`PrimitiveTorus`) +- SOLID › CREATE › Rectangular Pattern (`PatternRectangular`) +- SOLID › CREATE › Circular Pattern (`PatternCircular`) +- SOLID › CREATE › Mirror (`MirrorCommand`) - SOLID › MODIFY › Fillet (`FusionFilletEdgesCommand`) - SOLID › MODIFY › Chamfer (`FusionChamferCommand`) - SOLID › MODIFY › Combine (`FusionCombineCommand`) +- SOLID › MODIFY › Split Body (`FusionSplitBodyCommand`) - SOLID › MODIFY › Move/Copy (`FusionMoveCommand`) - SOLID › MODIFY › Delete (`FusionDeleteCommand`) - SOLID › MODIFY › Change Parameters (`ChangeParameterCommand`) - SOLID › MODIFY › Compute All (`FusionComputeAllCommand`) +- SOLID › CONSTRUCT › Offset Plane (`ConstructionPlaneOffsetFromPlaneCommand`) +- SOLID › CONSTRUCT › Plane at Angle (`ConstructionPlaneAtAngleCommand`) - SOLID › INSPECT › Measure (`MeasureCommand`) - SKETCH › CREATE › Line (`DrawPolyline`) - SKETCH › CREATE › 2-Point Rectangle (`ShapeRectangleTwoPoint`) @@ -159,16 +166,12 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool - SOLID › CREATE › Rib (`FusionRibCommand`) - SOLID › CREATE › Web (`FusionWebCommand`) - SOLID › CREATE › Emboss (`EmbossCmd`) -- SOLID › CREATE › Hole (`FusionHoleCommand`) - SOLID › CREATE › Thread (`FusionThreadCommand`) - SOLID › CREATE › Coil (`PrimitiveCoil`) - SOLID › CREATE › Pipe (`PrimitivePipe`) - SOLID › CREATE › Bounding Solid (`StockModelCommand`) -- SOLID › CREATE › Rectangular Pattern (`PatternRectangular`) -- SOLID › CREATE › Circular Pattern (`PatternCircular`) - SOLID › CREATE › Pattern on Path (`PatternOnPath`) - SOLID › CREATE › Geometric Pattern (`FusionPatternGeometricCommand`) -- SOLID › CREATE › Mirror (`MirrorCommand`) - SOLID › CREATE › Thicken (`FusionSurfaceThickenCommand`) - SOLID › CREATE › Boundary Fill (`SurfaceSculpt`) - SOLID › CREATE › Find Features (`FusionFindFeaturesCommand`) @@ -187,7 +190,6 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool - SOLID › MODIFY › Offset Face (`FusionOffsetFacesCommand`) - SOLID › MODIFY › Replace Face (`FusionReplaceFaceCommand`) - SOLID › MODIFY › Split Face (`FusionSplitFaceCommand`) -- SOLID › MODIFY › Split Body (`FusionSplitBodyCommand`) - SOLID › MODIFY › Silhouette Split (`FusionPartingLineSplitCmd`) - SOLID › MODIFY › Align (`AlignCmd`) - SOLID › MODIFY › Remove (`SoftDeleteCommand`) @@ -228,8 +230,6 @@ Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the tool - SOLID › CONFIGURE › Display Configuration Table (`FusionShowDesignConfigPanelCmd`) - SOLID › CONFIGURE › Create Configuration Rules (`FusionConfigurationRulesCmd`) - SOLID › CONSTRUCT › User Coordinate System (UCS) (`CoordinateSystemCommand`) -- SOLID › CONSTRUCT › Offset Plane (`ConstructionPlaneOffsetFromPlaneCommand`) -- SOLID › CONSTRUCT › Plane at Angle (`ConstructionPlaneAtAngleCommand`) - SOLID › CONSTRUCT › Tangent Plane (`ConstructionTangentPlaneCommand`) - SOLID › CONSTRUCT › Midplane (`ConstructionMidPlaneCommand`) - SOLID › CONSTRUCT › Perpendicular Plane (`ConstructionPlanePerpendicularToPlaneCommand`)