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 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 19:22:27 +00:00
1 parent 42e0362f5b
commit 431b87c2b9
12 files changed
+396 -41

No files matched your search

+21
View File
@@ -224,6 +224,27 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
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::<Vec<f64>>()));
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}})),
_ => {
+44
View File
@@ -216,6 +216,18 @@ pub enum FeatureKind {
features: Vec<String>,
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<String>,
#[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 {
+76 -1
View File
@@ -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<String, Value>, 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<Body> {
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<String>) {
| 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<String, Value>, 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(())
+53
View File
@@ -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<Value> {
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<Value> {
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<String> {
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 })
}
+5
View File
@@ -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() }
+41 -6
View File
@@ -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<mt::Vertex> = chain
let verts: Vec<mt::Vertex> = 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<mt::Edge> = 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)
})
+49
View File
@@ -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)));
}
}
+65 -1
View File
@@ -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::<Vec<_>>())
&& 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<V
}
}
/// Solve a 3×3 system (Cramer's rule).
fn solve3(m: [[f64; 3]; 3], r: [f64; 3]) -> 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<TopoCounts> {
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 })
}
+8
View File
@@ -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<Pos2> =
(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);
+1
View File
@@ -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",
}
+10 -10
View File
@@ -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 | |
+23 -23
View File
@@ -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`)