diff --git a/crates/engine/src/cmd/joints.rs b/crates/engine/src/cmd/joints.rs index 4b8fdbfe..1a346467 100644 --- a/crates/engine/src/cmd/joints.rs +++ b/crates/engine/src/cmd/joints.rs @@ -305,8 +305,29 @@ fn origin_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result Ok(o), + // A circle given by its centre, where no face lies (#393). + Err(e) => match (what == "circle").then(|| circle_centre(s, occurrence, local(world))).flatten() { + Some(snap) => Ok(JointOrigin { occurrence, snap }), + None => Err(bad(cmd, e.to_string())), + }, + } +} + +/// The round end of a cylindrical face of the occurrence's bodies centred at `pick`: a point on +/// its axis there, pointing out of that end (as picking its circular edge gives). +fn circle_centre(s: &Session, occurrence: u64, pick: Vec3) -> Option { + let component = if occurrence == 0 { 0 } else { s.doc.occurrences.iter().find(|o| o.id == occurrence)?.component }; + let st = s.model.state(); + let c = st + .bodies + .iter() + .filter(|b| s.doc.model_body_component(b) == component) + .find_map(|b| solidcraft_doc::kernel::cylinder_end_at(&b.body, pick))?; + let along = (pick - c.axis_point).dot(c.axis); + let (at, dir) = if (along - c.start).abs() <= (c.end - along).abs() { (c.start, -1.0) } else { (c.end, 1.0) }; + Some(Snap::Point { point: c.axis_point + c.axis * at, z: Some(c.axis * dir) }) } pub(crate) fn joint_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result { @@ -699,7 +720,10 @@ fn size_param(s: &Session, p: &Value, key: &str, cmd: &str) -> Result { match p.get(key) { Some(Value::Number(n)) => n.as_f64().map(Size::Length).ok_or_else(|| bad(cmd, format!("`{key}` must be a finite length"))), Some(Value::String(t)) if t.len() <= 1000 => Ok(Size::Expr(t.trim().to_string())), - Some(o @ Value::Object(m)) if m.contains_key("circle") => Ok(Size::Circle(origin_param(s, Some(o), cmd)?)), + Some(o @ Value::Object(m)) if m.contains_key("circle") => match origin_param(s, Some(o), cmd)? { + o @ JointOrigin { snap: Snap::CircleCenter { .. }, .. } => Ok(Size::Circle(o)), + _ => Err(bad(cmd, format!("`{key}`: give a point on the round face, not its centre"))), + }, _ => Err(bad(cmd, format!("`{key}` must be a length (mm or an expression) or a round face ({{circle: [x, y, z]}})"))), } } diff --git a/crates/engine/src/cmd/joints_tests.rs b/crates/engine/src/cmd/joints_tests.rs index e86571f8..2abeadb5 100644 --- a/crates/engine/src/cmd/joints_tests.rs +++ b/crates/engine/src/cmd/joints_tests.rs @@ -34,6 +34,40 @@ fn leaf(s: &mut Session, name: &str) { run(s, "solid.cylinder", json!({"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"})); } +#[test] +fn circle_given_by_its_centre_takes_that_round_end() { + // #393: `circle` at the centre of a round end, as the parameters document, was refused with + // "no cylindrical face there" (no face lies at a centre). + let mut s = Session::default(); + run(&mut s, "component.create", json!({"name": "Plate"})); + run(&mut s, "solid.box", json!({"length": 30, "width": 20, "height": 6, "corner": [-15, -10, 0]})); + run(&mut s, "component.activate", json!({"component": "root"})); + run(&mut s, "component.create", json!({"name": "Knob"})); + run(&mut s, "solid.cylinder", json!({"base": [0, 0, 6], "axis": [0, 0, 1], "radius": 10, "height": 10, "operation": "new"})); + let r = run( + &mut s, + "joint.create", + json!({"type": "cylindrical", "a": {"occurrence": "Plate", "face": [0, 0, 6]}, "b": {"occurrence": "Knob", "circle": [0, 0, 6]}, "name": "J"}), + ); + assert_eq!(r["conflicts"].as_array().map(Vec::len), Some(0), "{r}"); + // The knob's bottom circle already sits on the plate's top: its axis stays put (it may turn + // about it, to line up the two frames' x). + for z in [6.0, 16.0] { + assert!(near(place(&s, "Knob", Vec3::new(0.0, 0.0, z)), Vec3::new(0.0, 0.0, z)), "{:?}", place(&s, "Knob", Vec3::new(0.0, 0.0, z))); + } + // The top end's centre: its axis points up, out of the knob. + run(&mut s, "joint.origin", json!({"occurrence": "Knob", "circle": [0, 0, 16], "name": "Top"})); + let top = s.doc.assembly.origins.iter().find(|o| o.name == "Top").map(|o| o.origin.snap.clone()); + assert!( + matches!(top, Some(solidcraft_doc::joints::Snap::Point { point, z: Some(z) }) if near(point, Vec3::new(0.0, 0.0, 16.0)) && near(z, Vec3::Z)), + "{top:?}" + ); + // Off the axis is still refused, and so is a coupling size (it follows a face). + assert!(s.execute("joint.origin", &json!({"occurrence": "Knob", "circle": [3, 0, 6]})).is_err()); + let e = s.execute("joint.screw", &json!({"joint": "J", "pitch": {"occurrence": "Knob", "circle": [0, 0, 6]}})).unwrap_err(); + assert!(e.to_string().contains("not its centre"), "{e}"); +} + #[test] fn revolute_hinge_turns_about_the_knuckle() { let mut s = Session::default(); diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index daf37d7b..b4907752 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -181,7 +181,7 @@ pub use surfaces::{ sweep_sheet_points, sweep_sheet_points_with, sweep_sheet_with, thicken, trim, unstitch, }; pub use sweep_path::{SweepControl, SweepOrientation, sweep_path, sweep_path_with}; -pub use topo::{CylinderFace, cylinder_face_at}; +pub use topo::{CylinderFace, cylinder_end_at, cylinder_face_at}; pub use topo::{TopoCounts, merged_topology, seam_flags}; pub use tspline::tspline_sheet; diff --git a/crates/kernel/src/topo.rs b/crates/kernel/src/topo.rs index 32c5c6aa..60be2b42 100644 --- a/crates/kernel/src/topo.rs +++ b/crates/kernel/src/topo.rs @@ -663,6 +663,32 @@ pub fn cylinder_face_at(b: &Body, p: Vec3) -> Option { let tol = (size * 1e-4).max(1e-6); let surfs = classify_faces(b, &mesh, nf, tol); let Surf::Cylinder { axis, p: ap, r } = *surfs.get(face as usize)? else { return None }; + Some(cylinder_extent(&mesh, face, axis, ap, r)) +} + +/// The cylindrical face one of whose end circles is centred at `p` (a circle given by its +/// centre, where no face lies), the nearest when several are. +pub fn cylinder_end_at(b: &Body, p: Vec3) -> Option { + let size = b.size(); + let tol = (size * 1e-3).max(1e-3); + let mesh = b.tessellate(tol).ok()?; + let surfs = classify_faces(b, &mesh, b.face_count(), (size * 1e-4).max(1e-6)); + let mut best: Option<(f64, CylinderFace)> = None; + for (face, s) in surfs.iter().enumerate() { + let Surf::Cylinder { axis, p: ap, r } = *s else { continue }; + let c = cylinder_extent(&mesh, u32::try_from(face).ok()?, axis, ap, r); + let v = p - c.axis_point; + let along = v.dot(c.axis); + let off = (v - c.axis * along).len() + (along - c.start).abs().min((c.end - along).abs()); + if off <= tol && best.is_none_or(|(bo, _)| off < bo) { + best = Some((off, c)); + } + } + best.map(|(_, c)| c) +} + +/// The extent along its axis, and the side, of a cylindrical face of a tessellated body. +fn cylinder_extent(mesh: &Mesh, face: u32, axis: Vec3, ap: Vec3, r: f64) -> CylinderFace { let mut lo = f64::INFINITY; let mut hi = f64::NEG_INFINITY; let mut inward = 0.0; @@ -679,5 +705,5 @@ pub fn cylinder_face_at(b: &Body, p: Vec3) -> Option { inward += n.dot(radial); } } - Some(CylinderFace { axis_point: ap, axis, radius: r, start: lo, end: hi, internal: inward < 0.0 }) + CylinderFace { axis_point: ap, axis, radius: r, start: lo, end: hi, internal: inward < 0.0 } }