Faces by name in the face commands; model.faces gives a point on each face (#1243)

This commit is contained in:
NOWN-DEV authored and GitHub committed 2026-10-14 20:18:13 -04:00
1 parent 2cab09f65b
commit 5c6f70cc00
17 files changed
+480 -29

No files matched your search

+27
View File
@@ -371,3 +371,30 @@ pub fn faces_with_stem(b: &ModelBody, name: &str) -> Vec<usize> {
pub(crate) fn locate_on(body: &ModelBody, name: &str, p: Vec3) -> Option<FaceHit> {
locate_in(std::iter::once((0, body)), name, p)
}
/// The one face a name `model.faces` lists finds, on the body called `body` or, without one, on
/// any body. `Err` says why there is no such face, or why the name does not name just one (the
/// pieces of a split face, or the same face on several bodies): a command that takes a face by
/// its name has no point to tell the pieces apart by.
pub fn locate_one(st: &ModelState, body: Option<&str>, name: &str) -> Result<FaceHit, String> {
if let Some(n) = body
&& !st.bodies.iter().any(|b| b.name == n)
{
return Err(format!("no body `{n}`"));
}
let bodies: Vec<(usize, &ModelBody)> =
st.bodies.iter().enumerate().filter(|(_, b)| !b.body.is_mesh() && body.is_none_or(|n| b.name == n)).collect();
// As [`locate_face`]: the names of several bodies are worked out on all cores first.
if bodies.len() > 1 && legacy_name(name).is_none() {
solidcraft_kernel::for_each_on_threads(&bodies, 16, |(_, b)| {
let _ = naming::names(b);
});
}
let hit = locate_in(bodies.into_iter(), name, Vec3::ZERO).ok_or_else(|| format!("no face is named `{name}` (`model.faces` lists the names)"))?;
if hit.split {
return Err(format!(
"`{name}` names more than one face (pieces of a split face, or faces of several bodies): give `body`, or the name of one piece"
));
}
Ok(hit)
}
+66 -1
View File
@@ -76,7 +76,7 @@ impl NameCell {
}
}
pub use crate::name_lookup::{EdgeHit, FaceHit, LEGACY_MARK, face_has_name, faces_with_stem, legacy_name, locate_edge, locate_face};
pub use crate::name_lookup::{EdgeHit, FaceHit, LEGACY_MARK, face_has_name, faces_with_stem, legacy_name, locate_edge, locate_face, locate_one};
pub use crate::piece_names::parent_piece;
/// Deeper chains than this are named by position only (bounded work and stack).
@@ -756,6 +756,71 @@ pub fn face_name_at_body(body: &ModelBody, point: Vec3) -> Option<String> {
face_names(body).get(index).filter(|name| !name.is_empty()).cloned()
}
/// Where a face is, for a script: a point that lies on it and picks it, and the plane, cylinder,
/// cone, sphere or torus it is part of (when it is one).
#[derive(Clone, Copy, Debug)]
pub struct FaceSpot {
pub point: Vec3,
pub surface: Option<solidcraft_kernel::FaceSurface>,
}
/// The incentre and inradius of the roomiest triangle (greatest inradius) of each face of a mesh,
/// of face `only` alone if given.
fn roomiest(m: &Mesh, only: Option<usize>) -> Vec<Option<(f64, Vec3)>> {
let mut best = vec![None; m.tri_face.iter().map(|f| *f as usize + 1).max().unwrap_or(0)];
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
let f = *f as usize;
if only.is_some_and(|i| i != f) {
continue;
}
let Some([a, b, c]) = m.tri(t) else { continue };
// The incentre weighs each corner by the side opposite it; the inradius is area / half the perimeter.
let (la, lb, lc) = (b.dist(c), c.dist(a), a.dist(b));
let perimeter = la + lb + lc;
let radius = (b - a).cross(c - a).len() / perimeter;
let centre = (a * la + b * lb + c * lc) / perimeter;
if let Some(slot) = best.get_mut(f)
&& radius.is_finite()
&& centre.is_finite()
&& slot.is_none_or(|(old, _)| radius > old)
{
*slot = Some((radius, centre));
}
}
best
}
/// The spot of face `index` of `b` from its roomiest triangle: the triangle's incentre, so a point
/// that lies on the face however it is shaped (the centroid of a plate with a hole is in the hole,
/// that of a half cylinder inside the cylinder) and well away from its edges, so a pick there finds
/// this face. On a face of an analytic surface the point is moved onto the surface itself (a facet
/// of a curved face lies inside it by its sag), as far as that stays well inside the triangle.
fn spot(b: &ModelBody, index: usize, roomiest: Option<(f64, Vec3)>) -> Option<FaceSpot> {
let (radius, centre) = roomiest?;
let surface = solidcraft_kernel::face_surface(&b.body, index);
let point = surface.and_then(|s| s.nearest(centre)).filter(|q| q.dist(centre) <= radius * 0.5).unwrap_or(centre);
Some(FaceSpot { point, surface })
}
/// [`face_spot`] of every face of a body (`None`: the face has no triangles), in one pass.
pub fn face_spots(b: &ModelBody) -> Vec<Option<FaceSpot>> {
roomiest(&b.mesh(), None).into_iter().enumerate().map(|(i, best)| spot(b, i, best)).collect()
}
/// Where face `index` of `b` is (`None`: it has no triangles).
pub fn face_spot(b: &ModelBody, index: usize) -> Option<FaceSpot> {
spot(b, index, roomiest(&b.mesh(), Some(index)).get(index).copied().flatten())
}
/// A point on the face with this name, the name `model.faces` lists for it, on the body called
/// `body` or, without one, on any body (see [`locate_one`]): what a command that takes a face as
/// a point is given instead when the face is named.
pub fn named_face_point(st: &ModelState, body: Option<&str>, name: &str) -> Result<Vec3, String> {
let hit = locate_one(st, body, name)?;
let b = st.bodies.get(hit.body).ok_or_else(|| format!("no face is named `{name}`"))?;
face_spot(b, hit.face).map(|spot| spot.point).ok_or_else(|| format!("face `{name}` has no surface to pick"))
}
/// The point on the face with this name nearest `p` (a split face: its nearest piece, with
/// `true` for "not exactly that face": split, or merged into another); `None` when no face has
/// the name.
+17 -1
View File
@@ -6,7 +6,7 @@ use serde_json::{Value, json};
use solidcraft_doc::{EmbossFace, apply_point, mat_inverse, mat_mul};
use solidcraft_geom::{Mesh, Vec2, Vec3};
use crate::params::bad;
use crate::params::{bad, vec3};
use crate::{Result, Session};
fn point_triangle_dist(p: Vec3, a: Vec3, b: Vec3, c: Vec3) -> f64 {
@@ -57,6 +57,22 @@ pub(super) fn face_is_planar(s: &Session, p: Vec3) -> Option<bool> {
Some(ns.iter().all(|n| n.dot(n0) > 1.0 - 1e-6))
}
/// A face given to a command: a point `[x, y, z]` on it (or `{point}`), or its name as
/// `model.faces` lists it, looked up on `body` (any body without one). A name becomes a point on
/// that face, which the feature keeps like a picked one, so it also keeps the face's name.
/// The point is in the frame of the command's other picks: the component's own when
/// they were mapped into it, else the world's.
pub(super) fn face_ref(s: &Session, v: &Value, body: Option<&str>, cmd: &str) -> Result<Vec3> {
if let Some(name) = v.as_str() {
if name.is_empty() || name.len() > 512 {
return Err(bad(cmd, "a face name is 1…512 characters"));
}
let st = if crate::frames::mapped() { s.model.state() } else { s.world_state() };
return solidcraft_doc::naming::named_face_point(&st, body, name).map_err(|why| bad(cmd, why));
}
vec3(v).or_else(|| v.get("point").and_then(vec3)).ok_or_else(|| bad(cmd, "a face is a point [x, y, z] on it, or its name from model.faces"))
}
/// The body face under a point: (mesh, face index, distance, body).
fn face_at(s: &Session, p: Vec3) -> Option<(std::sync::Arc<Mesh>, u32, f64, String)> {
face_at_in(s, p, None).ok().flatten()
+13 -13
View File
@@ -67,11 +67,11 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("face.delete", "Delete Face", delete_face)
.at("SOLID", "MODIFY")
.icon("delete")
.params("faces: [[x,y,z] points on the faces]; body?; heal?: bool (default true: the neighbouring planes close the gap, as when a rounded edge, hole or boss is taken off; false: the gap stays open and the faces left are a surface body)"),
.params("faces: [[x,y,z] points on the faces, or face names from model.faces]; body?; heal?: bool (default true: the neighbouring planes close the gap, as when a rounded edge, hole or boss is taken off; false: the gap stays open and the faces left are a surface body)"),
CommandSpec::new("solid.offset_face", "Offset Face", offset_face)
.at("SOLID", "MODIFY")
.icon("offset_face")
.params("faces: [[x,y,z] points on planar faces]; distance: expr (positive: outward); body?"),
.params("faces: [[x,y,z] points on planar faces, or face names from model.faces]; distance: expr (positive: outward); body?"),
CommandSpec::new("solid.thread", "Thread", thread)
.at("SOLID", "CREATE")
.icon("thread")
@@ -79,11 +79,11 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.shell", "Shell", shell)
.at("SOLID", "MODIFY")
.icon("shell")
.params("faces: [[x,y,z] points on the faces to remove]; thickness: expr; direction?: inside|outside|both; tangent_chain?: bool (also open faces joined smoothly to the picked ones); body?"),
.params("faces: [[x,y,z] points on the faces to remove, or face names from model.faces]; thickness: expr; direction?: inside|outside|both; tangent_chain?: bool (also open faces joined smoothly to the picked ones); body?"),
CommandSpec::new("solid.draft", "Draft", draft)
.at("SOLID", "MODIFY")
.icon("draft")
.params("faces: [[x,y,z]]; angle: expr; neutral: XY|XZ|YZ|plane name|{origin, normal}; pull?: [x,y,z] (default: neutral plane normal); body?"),
.params("faces: [[x,y,z] points, or face names from model.faces]; angle: expr; neutral: XY|XZ|YZ|plane name|{origin, normal}; pull?: [x,y,z] (default: neutral plane normal); body?"),
CommandSpec::new("construct.plane.offset", "Offset Plane", plane_offset)
.at("SOLID", "CONSTRUCT")
.icon("plane")
@@ -96,7 +96,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SOLID", "MODIFY")
.icon("split")
.params(
"faces: [[x,y,z] points on the faces to split]; body?; and the splitting tool: plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id or name (its curves, along the sketch's normal)",
"faces: [[x,y,z] points on the faces to split, or face names from model.faces]; body?; and the splitting tool: plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id or name (its curves, along the sketch's normal)",
),
CommandSpec::new("solid.split_body", "Split Body", split_body)
.at("SOLID", "MODIFY")
@@ -1323,7 +1323,7 @@ fn plane_angle(s: &mut Session, p: &Value) -> Result<Value> {
fn split_face(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.split_face";
let faces = face_points(p, cmd)?;
let faces = face_points(s, p, cmd)?;
let body = str_(p, "body").map(str::to_string);
let xy = || solidcraft_doc::PlaneRef::Origin { name: "XY".into() };
let kind = if let Some(t) = p.get("tool").filter(|t| !t.is_null()) {
@@ -1542,17 +1542,17 @@ fn sketch_hole_points(s: &Session, sketch: u64) -> Vec<String> {
ss.sketch.points.iter().enumerate().filter(|(i, p)| p.id != "origin" && !ends.contains(i)).map(|(_, p)| p.id.clone()).collect()
}
fn face_points(p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
let a = p.get("faces").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`faces` must be a list of points"))?;
fn face_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
let a = p.get("faces").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`faces` must be a list of points or face names"))?;
if a.is_empty() || a.len() > 1000 {
return Err(bad(cmd, "select 1…1000 faces"));
}
a.iter().map(|v| vec3(v).or_else(|| v.get("point").and_then(vec3)).ok_or_else(|| bad(cmd, "a face is a point [x, y, z] on it"))).collect()
a.iter().map(|v| super::face::face_ref(s, v, str_(p, "body"), cmd)).collect()
}
fn shell(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.shell";
let faces = face_points(p, cmd)?;
let faces = face_points(s, p, cmd)?;
let thickness = req_expr(cmd, p, "thickness")?;
check_expr(s, &thickness, Kind::Length, cmd, "thickness")?;
let direction = str_(p, "direction").map(str::to_ascii_lowercase).unwrap_or_default();
@@ -1574,7 +1574,7 @@ fn shell(s: &mut Session, p: &Value) -> Result<Value> {
fn draft(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.draft";
let faces = face_points(p, cmd)?;
let faces = face_points(s, p, cmd)?;
let angle = req_expr(cmd, p, "angle")?;
check_expr(s, &angle, Kind::Angle, cmd, "angle")?;
let neutral = plane_param(s, p.get("neutral"), cmd)?;
@@ -1715,14 +1715,14 @@ fn scale(s: &mut Session, p: &Value) -> Result<Value> {
}
fn delete_face(s: &mut Session, p: &Value) -> Result<Value> {
let faces = face_points(p, "face.delete")?;
let faces = face_points(s, p, "face.delete")?;
let heal = bool_(p, "heal").unwrap_or(true);
add_feature(s, p, FeatureKind::DeleteFace { faces, body: str_(p, "body").map(str::to_string), heal })
}
fn offset_face(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.offset_face";
let faces = face_points(p, cmd)?;
let faces = face_points(s, p, cmd)?;
let distance = req_expr(cmd, p, "distance")?;
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
add_feature(s, p, FeatureKind::OffsetFace { faces, distance, body: str_(p, "body").map(str::to_string) })
+5 -5
View File
@@ -35,7 +35,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.replace_face", "Replace Face", replace_face)
.at("SOLID", "MODIFY")
.icon("offset_face")
.params("faces: [[x,y,z] points on planar faces]; target: plane (XY|XZ|YZ|construction plane|{origin, normal}) | target_face: [x,y,z] on a parallel planar face; body?"),
.params("faces: [[x,y,z] points on planar faces, or face names from model.faces]; target: plane (XY|XZ|YZ|construction plane|{origin, normal}) | target_face: [x,y,z] on a parallel planar face; body?"),
CommandSpec::new("solid.align", "Align", align)
.at("SOLID", "MODIFY")
.icon("move")
@@ -316,17 +316,17 @@ fn infer_emboss_selection(
}
Ok(chosen)
}
fn face_points(p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
let a = p.get(k).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{k}` must list points [x, y, z]")))?;
fn face_points(s: &Session, p: &Value, k: &str, cmd: &str) -> Result<Vec<Vec3>> {
let a = p.get(k).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{k}` must list points [x, y, z] or face names")))?;
if a.is_empty() || a.len() > 1000 {
return Err(bad(cmd, format!("`{k}` must list 1…1000 points")));
}
a.iter().map(|v| vec3(v).ok_or_else(|| bad(cmd, format!("`{k}` must contain [x, y, z] points")))).collect()
a.iter().map(|v| super::face::face_ref(s, v, str_(p, "body"), cmd)).collect()
}
fn replace_face(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.replace_face";
let faces = face_points(p, "faces", cmd)?;
let faces = face_points(s, p, "faces", cmd)?;
for f in &faces {
if super::face::face_normal(s, *f).is_none() {
return Err(bad(cmd, "not supported yet: replacing a face that is not planar"));
+14 -3
View File
@@ -17,7 +17,10 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("document.inspect", "Inspect Design", inspect).noundo().params("measure?: bool (include body measurements)"),
CommandSpec::new("sketch.inspect", "Inspect Sketch", sketch_inspect).noundo().params("sketch?: id|name (default active)"),
CommandSpec::new("model.edges", "List Edges", model_edges).noundo().params("body: name"),
CommandSpec::new("model.faces", "List Faces", model_faces).noundo().params("body: name"),
CommandSpec::new("model.faces", "List Faces", model_faces).noundo().params(
"body: name → per face: index, name (what face.delete, sheet.convert `face`, solid.shell … take in place of a point), exact, area, centroid (not always on the face), \
plane_normal, point (a point on the face that picks it), cylinder? ({origin, axis, radius} of a cylindrical face)",
),
CommandSpec::new("model.threads", "List Threads", model_threads).noundo(),
CommandSpec::new("material.assign", "Physical Material", physical_material)
.at("SOLID", "MODIFY")
@@ -283,13 +286,21 @@ fn model_faces(s: &mut Session, p: &Value) -> Result<Value> {
let b = body_of(&st, p, "model.faces")?;
let names = solidcraft_doc::naming::face_names(b);
let tol = (b.body.size() * 2e-3).max(1e-3);
let spots = solidcraft_doc::naming::face_spots(b);
let faces: Vec<Value> = b
.body
.faces(tol)?
.iter()
.map(|f| {
json!({"index": f.index, "name": names.get(f.index), "exact": solidcraft_doc::naming::face_name_exact(b, f.index),
"area": f.area, "centroid": f.centroid, "plane_normal": f.plane_normal})
let spot = spots.get(f.index).copied().flatten();
let mut v = json!({"index": f.index, "name": names.get(f.index), "exact": solidcraft_doc::naming::face_name_exact(b, f.index),
"area": f.area, "centroid": f.centroid, "plane_normal": f.plane_normal, "point": spot.map(|x| x.point)});
if let (Some(solidcraft_kernel::FaceSurface::Cylinder { origin, axis, radius }), Some(o)) =
(spot.and_then(|x| x.surface), v.as_object_mut())
{
o.insert("cylinder".into(), json!({"origin": origin, "axis": axis, "radius": radius}));
}
v
})
.collect();
Ok(json!({"body": b.name, "faces": faces}))
+3 -2
View File
@@ -38,7 +38,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.at("SHEET METAL", "CREATE")
.icon("convert_sheet")
.params(
"body; face: [x,y,z] on its large flat face (a plate of even thickness); rule?; \
"body; face: [x,y,z] on its large flat face (a plate of even thickness), or that face's name from model.faces; rule?; \
bends?: [[x,y,z] on sharp edges] (a solid or a shelled body: `face` is the fixed face and the flat faces reached from it \
across these edges become the sheet; a shell keeps its thickness, a solid takes the rule's inward; \
edges between two of those faces that are no bends are cut open with the rule's gap)",
@@ -297,7 +297,8 @@ fn convert(s: &mut Session, p: &Value) -> Result<Value> {
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
}
let face = p.get("face").and_then(vec3).ok_or_else(|| bad(cmd, "`face` must be a point [x, y, z] on the plate's face"))?;
let face = p.get("face").ok_or_else(|| bad(cmd, "`face` must be a point [x, y, z] on the plate's face, or its name from model.faces"))?;
let face = super::face::face_ref(s, face, Some(&body), cmd)?;
// With bend edges, a solid or shelled body; without, a plate.
let bends = match p.get("bends") {
None | Some(Value::Null) => Vec::new(),
+242
View File
@@ -0,0 +1,242 @@
//! Faces by name: `model.faces` lists a point on each face (and a cylinder's axis and radius), and
//! the commands that take a face as a point take the name `model.faces` lists instead.
use std::f64::consts::PI;
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn volume(s: &mut Session, b: &str) -> f64 {
run(s, "inspect.measure", json!({ "bodies": [b] }))["bodies"][0]["volume_mm3"].as_f64().unwrap_or(f64::NAN)
}
fn vec(v: &Value) -> Vec3 {
Vec3::new(v[0].as_f64().unwrap_or(f64::NAN), v[1].as_f64().unwrap_or(f64::NAN), v[2].as_f64().unwrap_or(f64::NAN))
}
/// A `l` x `w` x `h` plate with a through hole of radius 5 in the middle.
fn plate_with_hole(s: &mut Session, l: f64, w: f64, h: f64) {
run(s, "solid.box", json!({"length": l, "width": w, "height": h, "body_name": "Plate"}));
run(s, "solid.cylinder", json!({"base": [l / 2.0, w / 2.0, -1], "radius": 5, "height": h + 2.0, "body_name": "Drill"}));
run(s, "solid.combine", json!({"target": "Plate", "tools": ["Drill"], "operation": "cut"}));
}
fn faces(s: &mut Session, body: &str) -> Vec<Value> {
run(s, "model.faces", json!({ "body": body }))["faces"].as_array().cloned().unwrap_or_default()
}
/// The name of the face looking up (+z), and the names of the hole's walls (the cylindrical faces).
fn top_and_hole_names(s: &mut Session) -> (String, Vec<String>) {
let all = faces(s, "Plate");
let name = |f: &Value| f["name"].as_str().unwrap_or_default().to_string();
let top = all.iter().find(|f| f["plane_normal"][2].as_f64().is_some_and(|z| z > 0.99)).map(name).unwrap_or_default();
(top, all.iter().filter(|f| f.get("cylinder").is_some()).map(name).collect())
}
/// A 60 x 40 x 10 plate with a hole of radius 5: its top face is a rectangle with a hole in it,
/// whose centroid (the plate's middle, (30, 20, 10)) is in the hole; the hole's wall is two half
/// cylinders, whose centroids are inside the cylinder, 2r/π from its axis.
#[test]
fn model_faces_gives_a_point_on_each_face_and_the_hole_axis_and_radius() {
let mut s = Session::default();
plate_with_hole(&mut s, 60.0, 40.0, 10.0);
let all = faces(&mut s, "Plate");
let st = s.model.state();
let body = st.body("Plate").unwrap();
for f in &all {
let (at, index) = (vec(&f["point"]), f["index"].as_u64().unwrap() as usize);
let (distance, nearest) = solidcraft_doc::appearance::nearest_face(body, at).unwrap();
assert_eq!(nearest, index, "a pick at the point finds the face: {f}");
match f.get("cylinder") {
// On the cylinder itself, not only on its facets (which lie inside it).
Some(c) => {
let radial = (at.x - 30.0).hypot(at.y - 20.0);
assert!((radial - 5.0).abs() < 1e-9 && at.z > 0.0 && at.z < 10.0, "{at:?}");
// The centroid of a half cylinder's wall is 2r/π = 3.18 from the axis, not on the wall.
let centroid = vec(&f["centroid"]);
let inside = (centroid.x - 30.0).hypot(centroid.y - 20.0);
assert!((2.5..4.0).contains(&inside), "the centroid of a half cylinder is inside it: {inside}");
// The hole's axis: the line through (30, 20) along z, its radius 5.
let (origin, axis) = (vec(&c["origin"]), vec(&c["axis"]));
assert!((origin.x - 30.0).abs() < 1e-9 && (origin.y - 20.0).abs() < 1e-9, "{origin:?}");
assert!((axis.z.abs() - 1.0).abs() < 1e-9 && axis.x.abs() < 1e-9 && axis.y.abs() < 1e-9, "{axis:?}");
assert!((c["radius"].as_f64().unwrap_or(f64::NAN) - 5.0).abs() < 1e-9, "{c}");
}
None => assert!(distance < 1e-9, "the point lies on the face: {distance} {f}"),
}
}
// The hole's wall, whole: 2π·5·10 = 314.16 mm² (the facets of its halves add up to a little less).
let wall: f64 = all.iter().filter(|f| f.get("cylinder").is_some()).filter_map(|f| f["area"].as_f64()).sum();
assert!((wall - 2.0 * PI * 5.0 * 10.0).abs() < 0.02 * 2.0 * PI * 5.0 * 10.0, "{wall}");
// The top face is the rectangle less the hole; its centroid is in the hole, its point is not.
let top = all.iter().find(|f| f["plane_normal"][2].as_f64().is_some_and(|z| z > 0.99)).unwrap();
let (centroid, at) = (vec(&top["centroid"]), vec(&top["point"]));
assert!((centroid.x - 30.0).hypot(centroid.y - 20.0) < 1e-6, "{centroid:?}");
assert!(
(at.x - 30.0).hypot(at.y - 20.0) > 5.0 && (0.0..=60.0).contains(&at.x) && (0.0..=40.0).contains(&at.y) && (at.z - 10.0).abs() < 1e-9,
"{at:?}"
);
}
#[test]
fn delete_face_takes_the_faces_of_the_hole_by_name() {
let mut s = Session::default();
plate_with_hole(&mut s, 60.0, 40.0, 10.0);
// The hole takes π·5²·10 = 785.4 mm³ out of the 24000.
assert!((volume(&mut s, "Plate") - (24000.0 - 25.0 * PI * 10.0)).abs() < 1.0, "{}", volume(&mut s, "Plate"));
let (_, walls) = top_and_hole_names(&mut s);
assert!(!walls.is_empty());
let r = run(&mut s, "face.delete", json!({"faces": walls, "body": "Plate"}));
assert!(r["feature"].is_u64(), "{r}");
assert!((volume(&mut s, "Plate") - 24000.0).abs() < 24.0, "the plate closes up: {}", volume(&mut s, "Plate"));
// The feature keeps the faces' names, as it does for faces picked by a point.
assert_eq!(s.doc.features.last().map(|f| f.face_names.clone()), Some(walls));
}
#[test]
fn convert_to_sheet_metal_takes_the_plates_face_by_name() {
// A 100 x 60 x 2.5 plate with a hole in the middle: the centroid of its large face is in the
// hole, where no point of the face is.
let mut s = Session::default();
plate_with_hole(&mut s, 100.0, 60.0, 2.5);
let (top, _) = top_and_hole_names(&mut s);
run(&mut s, "sheet.convert", json!({"body": "Plate", "face": top}));
let flat = run(&mut s, "sheet.flat_pattern", json!({}));
for (i, want) in [100.0, 60.0, 2.5].into_iter().enumerate() {
assert!((flat["flat_size_mm"][i].as_f64().unwrap_or(f64::NAN) - want).abs() < 1e-6, "{flat}");
}
assert_eq!(flat["cutouts"].as_array().map(Vec::len), Some(1), "{flat}");
// The hole is 10 across (its outline is a polygon on the circle, a little less across).
let hole = flat["cutouts"][0].as_array().cloned().unwrap_or_default();
for k in 0..2 {
let at: Vec<f64> = hole.iter().filter_map(|p| p[k].as_f64()).collect();
let span = at.iter().cloned().fold(f64::MIN, f64::max) - at.iter().cloned().fold(f64::MAX, f64::min);
assert!(span > 9.8 && span < 10.0 + 1e-6, "{span}");
}
// The plate is still 100 x 60 x 2.5 less the hole: 15000 - π·5²·2.5 = 14803.65 mm³ for a round hole, a little more
// (less than 2% of the hole's 196.35 mm³) for a polygon inside the circle.
let (v, round) = (volume(&mut s, "Plate"), 15000.0 - 25.0 * PI * 2.5);
assert!(v >= round - 1e-6 && v - round < 0.02 * 25.0 * PI * 2.5, "{v}");
}
/// Shell, draft and offset face share the parser of Delete Face.
#[test]
fn shell_takes_the_face_to_open_by_name() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 30, "body_name": "Tray"}));
let all = faces(&mut s, "Tray");
let top = all.iter().find(|f| f["plane_normal"][2].as_f64().is_some_and(|z| z > 0.99)).unwrap();
run(&mut s, "solid.shell", json!({"faces": [top["name"]], "thickness": 2}));
// 100 x 60 x 30 less the cavity 96 x 56 x 28 (open at the top, floor and walls 2 thick).
assert!((volume(&mut s, "Tray") - (100.0 * 60.0 * 30.0 - 96.0 * 56.0 * 28.0)).abs() < 1e-6, "{}", volume(&mut s, "Tray"));
}
#[test]
fn split_face_takes_the_face_to_split_by_name() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 30, "body_name": "Tray"}));
let all = faces(&mut s, "Tray");
let top = all.iter().find(|f| f["plane_normal"][2].as_f64().is_some_and(|z| z > 0.99)).unwrap();
run(&mut s, "solid.split_face", json!({"faces": [top["name"]], "body": "Tray", "plane": {"origin": [0, 20, 0], "normal": [0, 1, 0]}}));
// The top is cut at y = 20 into 100 x 20 and 100 x 40.
let mut areas: Vec<f64> =
faces(&mut s, "Tray").iter().filter(|f| f["plane_normal"][2].as_f64().is_some_and(|z| z > 0.99)).filter_map(|f| f["area"].as_f64()).collect();
areas.sort_by(f64::total_cmp);
assert!(areas.len() == 2 && (areas[0] - 2000.0).abs() < 1e-6 && (areas[1] - 4000.0).abs() < 1e-6, "{areas:?}");
}
#[test]
fn a_name_that_finds_no_face_is_refused_and_the_design_is_unchanged() {
let mut s = Session::default();
plate_with_hole(&mut s, 60.0, 40.0, 10.0);
let (_, walls) = top_and_hole_names(&mut s);
let n = s.doc.features.len();
let mut refused = |p: Value| s.execute("face.delete", &p).unwrap_err().to_string();
assert!(refused(json!({"faces": ["F99:top"]})).contains("no face is named `F99:top`"));
assert!(refused(json!({"faces": [walls[0].clone()], "body": "Nope"})).contains("no body `Nope`"));
assert!(refused(json!({"faces": [""]})).contains("1…512"));
assert!(refused(json!({"faces": ["x".repeat(513)]})).contains("1…512"));
assert!(refused(json!({"faces": [true]})).contains("or its name"));
assert_eq!(s.doc.features.len(), n);
}
/// A 30 x 20 x 10 block with a hole of radius 3 in a component, moved 50 mm along x: `model.faces`
/// speaks the world, and a name finds its face where the component is shown.
#[test]
fn names_find_the_faces_of_a_moved_component() {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Part"}));
run(&mut s, "solid.box", json!({"length": 30, "width": 20, "height": 10, "body_name": "Block"}));
run(&mut s, "solid.cylinder", json!({"base": [15, 10, -1], "radius": 3, "height": 12, "operation": "cut"}));
run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [50, 0, 0]}));
let all = faces(&mut s, "Block");
let top = all.iter().find(|f| f["plane_normal"][2].as_f64().is_some_and(|z| z > 0.99)).unwrap();
assert!(vec(&top["point"]).x > 50.0, "{top}");
let walls: Vec<Value> = all.iter().filter(|f| f.get("cylinder").is_some()).map(|f| f["name"].clone()).collect();
assert!(!walls.is_empty());
let v = volume(&mut s, "Block");
assert!((v - (6000.0 - 9.0 * PI * 10.0)).abs() < 1.0, "{v}");
// Delete Face takes world points, Shell the points in the component's frame: either way the name finds the face.
run(&mut s, "face.delete", json!({"faces": walls, "body": "Block"}));
assert!((volume(&mut s, "Block") - 6000.0).abs() < 6.0, "{}", volume(&mut s, "Block"));
run(&mut s, "solid.shell", json!({"faces": [top["name"]], "thickness": 2, "body": "Block"}));
// 30 x 20 x 10 less the cavity 26 x 16 x 8.
assert!((volume(&mut s, "Block") - (6000.0 - 26.0 * 16.0 * 8.0)).abs() < 1e-6, "{}", volume(&mut s, "Block"));
}
fn top_name(all: &[Value]) -> Value {
all.iter().find(|f| f["plane_normal"][2].as_f64().is_some_and(|z| z > 0.99)).map(|f| f["name"].clone()).unwrap_or(Value::Null)
}
#[test]
fn offset_face_takes_the_face_by_name() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 100, "width": 60, "height": 30, "body_name": "Tray"}));
let top = top_name(&faces(&mut s, "Tray"));
run(&mut s, "solid.offset_face", json!({"faces": [top], "distance": 5, "body": "Tray"}));
// The top moves out 5 mm: 100 x 60 x 35.
assert!((volume(&mut s, "Tray") - 100.0 * 60.0 * 35.0).abs() < 1e-6, "{}", volume(&mut s, "Tray"));
}
/// A cylinder of radius 10 and height 20 has a side of two half cylinders, each of which
/// `model.faces` describes (its axis and radius); drafting them 5 degrees from the base plane makes
/// a frustum of radius 10 at the base and 10 - 20 tan 5 degrees at the top.
#[test]
fn draft_takes_a_cylinders_side_by_name() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius": 10, "height": 20, "body_name": "Pin"}));
let all = faces(&mut s, "Pin");
let sides: Vec<&Value> = all.iter().filter(|f| f.get("cylinder").is_some()).collect();
assert_eq!(sides.len(), 2, "two half cylinders: {all:?}");
for side in &sides {
assert!((side["cylinder"]["radius"].as_f64().unwrap_or(f64::NAN) - 10.0).abs() < 1e-9, "{side}");
}
let names: Vec<Value> = sides.iter().map(|f| f["name"].clone()).collect();
run(&mut s, "solid.draft", json!({"faces": names, "angle": "5 deg", "neutral": "XY", "body": "Pin"}));
let small = 10.0 - 20.0 * 5f64.to_radians().tan();
let want = PI * 20.0 / 3.0 * (100.0 + 10.0 * small + small * small);
assert!((volume(&mut s, "Pin") - want).abs() < 1e-3 * want, "{} vs {want}", volume(&mut s, "Pin"));
}
/// Replace Face takes the faces to replace by name; for a target on another body, the `point`
/// `model.faces` gives picks that body's face.
#[test]
fn replace_face_takes_the_face_by_name() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "A"}));
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 15, "corner": [20, 0, 0], "body_name": "B"}));
let (top_a, top_b) = (top_name(&faces(&mut s, "A")), top_name(&faces(&mut s, "B")));
let on_b = faces(&mut s, "B").into_iter().find(|f| f["name"] == top_b).map(|f| f["point"].clone());
run(&mut s, "solid.replace_face", json!({"faces": [top_a], "target_face": on_b, "body": "A"}));
// A's top moves up to B's, 15 high: 10 x 10 x 15.
assert!((volume(&mut s, "A") - 1500.0).abs() < 1e-6, "{}", volume(&mut s, "A"));
}
+2
View File
@@ -928,6 +928,8 @@ mod display_mesh_tests;
#[cfg(test)]
mod extent_tests;
#[cfg(test)]
mod face_name_tests;
#[cfg(test)]
mod hole_face_tests;
#[cfg(test)]
mod hole_matrix_tests;
+61
View File
@@ -50,3 +50,64 @@ pub fn face_surface(b: &Body, face: usize) -> Option<FaceSurface> {
Surf::Torus { o, a, big, h, r, .. } => FaceSurface::Torus { center: o + a * h, axis: a, major: big, minor: r },
})
}
impl FaceSurface {
/// The point of the whole surface (not only of its face) nearest `p`. `None` where every
/// point of a ring is as near (on a cylinder's or cone's axis, at a sphere's or torus's
/// centre), and for a point that is not finite.
pub fn nearest(&self, p: Vec3) -> Option<Vec3> {
if !p.is_finite() {
return None;
}
match *self {
FaceSurface::Plane { normal, point } => Some(p - normal * (p - point).dot(normal)),
FaceSurface::Cylinder { origin, axis, radius } => {
let w = p - origin;
let along = axis * w.dot(axis);
Some(origin + along + (w - along).normalized()? * radius)
}
FaceSurface::Cone { apex, axis, half_angle } => {
let w = p - apex;
let out = (w - axis * w.dot(axis)).normalized()?;
// The cone's line through the apex that p is nearest, in the plane of the axis and p.
let line = axis * half_angle.cos() + out * half_angle.sin();
Some(apex + line * w.dot(line).max(0.0))
}
FaceSurface::Sphere { center, radius } => Some(center + (p - center).normalized()? * radius),
FaceSurface::Torus { center, axis, major, minor } => {
let w = p - center;
let tube = center + (w - axis * w.dot(axis)).normalized()? * major;
Some(tube + (p - tube).normalized()? * minor)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn near(a: Option<Vec3>, b: Vec3) -> bool {
a.is_some_and(|a| a.dist(b) < 1e-12)
}
/// Each surface's nearest point, worked out by hand.
#[test]
fn the_nearest_point_of_each_surface() {
let plane = FaceSurface::Plane { normal: Vec3::Z, point: Vec3::new(0.0, 0.0, 4.0) };
assert!(near(plane.nearest(Vec3::new(3.0, 5.0, 9.0)), Vec3::new(3.0, 5.0, 4.0)));
// A cylinder of radius 5 about the line x = 1, y = 2: (7, 10, 3) is 10 from it along (3, 4)/5.
let cylinder = FaceSurface::Cylinder { origin: Vec3::new(1.0, 2.0, 0.0), axis: Vec3::Z, radius: 5.0 };
assert!(near(cylinder.nearest(Vec3::new(7.0, 10.0, 3.0)), Vec3::new(4.0, 6.0, 3.0)));
assert!(cylinder.nearest(Vec3::new(1.0, 2.0, 3.0)).is_none(), "on the axis every direction is as near");
let sphere = FaceSurface::Sphere { center: Vec3::new(1.0, 2.0, 3.0), radius: 5.0 };
assert!(near(sphere.nearest(Vec3::new(1.0, 2.0, 13.0)), Vec3::new(1.0, 2.0, 8.0)));
// A torus of ring radius 10 and tube radius 2 about Z: (14, 0, 0) is 2 beyond the tube's outside.
let torus = FaceSurface::Torus { center: Vec3::ZERO, axis: Vec3::Z, major: 10.0, minor: 2.0 };
assert!(near(torus.nearest(Vec3::new(14.0, 0.0, 0.0)), Vec3::new(12.0, 0.0, 0.0)));
// A cone of half-angle 45 degrees: (2, 0, 0) is √2 along its line (1, 0, 1)/√2.
let cone = FaceSurface::Cone { apex: Vec3::ZERO, axis: Vec3::Z, half_angle: std::f64::consts::FRAC_PI_4 };
assert!(cone.nearest(Vec3::new(2.0, 0.0, 0.0)).is_some_and(|q| q.dist(Vec3::new(1.0, 0.0, 1.0)) < 1e-12));
assert!(plane.nearest(Vec3::new(f64::NAN, 0.0, 0.0)).is_none());
}
}
+3 -2
View File
@@ -142,8 +142,9 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"body_topology",
"List edges and faces",
"The edges (index, midpoint, length, endpoints) and faces (index, area, centroid, plane normal) of a body. Use \
an edge midpoint as the [x,y,z] point that picks the edge for fillet and chamfer.",
"The edges (index, midpoint, length, endpoints) and faces (index, name, area, centroid, plane normal, a `point` that lies \
on the face, and for a cylindrical face its `cylinder`: origin, axis, radius) of a body. Use an edge midpoint as the \
[x,y,z] point that picks the edge for fillet and chamfer; a face's point or its name picks the face for the face commands.",
obj(json!({"body": string("Body name, e.g. \"Body1\"")}), &["body"]),
Effect::ReadOnly,
),
+10
View File
@@ -88,6 +88,16 @@ geometry, as it does for a face: it does not take whichever edge is nearest now.
without a name, by a build that kept only the point, is found by the edge nearest its point, as
it always was, and takes that edge's name when its sketch is next edited.
A command that takes a face as a point can be given a name `model.faces` lists instead
(`face.delete`, `solid.shell`, `solid.draft`, `solid.offset_face`, `solid.split_face`, the `faces`
of `solid.replace_face`, the `face` of `sheet.convert`). It is found with the same lookup
(`naming::locate_one`: the face with that name, or the one face answering to it, on the given
body or any body; a name that finds several faces is refused). The command takes a point on that
face (`naming::named_face_point`: the incentre of the face's roomiest triangle, moved onto the
surface of a plane, cylinder, cone, sphere or torus), which the feature stores like a picked one,
with the name it then gets as before. Nothing new is stored in the design. `model.faces` lists
that point for every face.
A hole placed by a position on a face is the exception to "the point nearest the reference's
point": its position is a place, not a pick. The hole follows its face along its own axis, so it
moves with the face when the face moves, and when the face no longer reaches the position it stays
+10
View File
@@ -155,6 +155,16 @@ still be added through `set`. The fields of the common features:
For any other feature, `timeline.inspect` shows the fields.
Where a command takes a face as a point (`face.delete`, `solid.shell`, `solid.draft`,
`solid.offset_face`, `solid.split_face`, the `faces` of `solid.replace_face`, and the `face` of
`sheet.convert`), a script can give the face's name instead, as `model.faces` lists it:
`{"command": "face.delete", "params": {"faces": ["F2:side#1", "F2:side#2"], "body": "Plate"}}`.
`model.faces` also lists a `point` on each face that picks it (the centroid of a face with a hole
in it is in the hole, that of a half cylinder inside the cylinder) and, for a cylindrical face,
its `cylinder`: `origin` and `axis` of its axis, and `radius`. A name that is more than one face
(the pieces of a split face, or one face of several bodies) is refused; `body` says which body to
look on.
Press **Delete** on anything selected (bodies, faces, sketches, features, planes, components,
canvases). It runs as one undoable step and lists the features that would go with it or fail.
+4
View File
@@ -143,6 +143,10 @@ and bends that close a ring.
"bends":[[50,0,0],[100,30,0],[50,60,0],[0,30,0]]}}
```
The `face` can also be the name `model.faces` lists for it (`"face":"F1:+z"`), which saves
computing a point on a face with holes or a curved outline: `model.faces` gives every face a
`point` that lies on it.
For a shop drawing or spreadsheet, `sheet.export_bend_table` returns CSV by default; choose
`format: "json"` for structured data. It includes the sheet rule, thickness, bend angles,
radii, allowances and flat centre-line coordinates. Lengths use millimetres and angles use
+1 -1
View File
@@ -131,7 +131,7 @@ They cover the workflow, the command shapes, how to verify with `measure`, and t
| `batch` | `commands: [{command, params}]`, `rollback?` (default true), `verbose?` (default true) | Run steps in order, stop at the first error and undo the completed steps. The reply names the failing step (`failed.index`, `failed.command`, `failed.error`) and every result before it. `verbose: false` keeps the reply small in large designs: feature steps leave out the full body list (`created_bodies`, `changed_bodies`, `removed_bodies` stay) and sketch steps the ids they repeat outside `created`. Bridged to an app that has `engine.batch`, the whole batch is one control request (one frame of the app). Older apps, and apps showing the agent cursor, get the steps one by one. |
| `inspect_design` | `measure?` (default true), `sketches?` (default true) | Design properties (`metadata`: project, author… and other tools' keys; set them with `execute` `document.metadata.set`), parameters, timeline (type, suppressed, rolled back, error, warning, timing), bodies (volume, area, centre of mass, bbox, face/edge/vertex counts, visible), sketches (curves, points, constraints, profiles, DOF, solve status, visible), selection, undo depth. Hide helper sketches, bodies or construction planes with `execute` `browser.visibility` (`{items, visible}` or `{folder, visible}`), and joint glyphs with `{joints: [id or name], visible}` or `{folder: "joints", visible}` (a hidden joint still constrains and drives; `joint.list` reports each joint's `visible`). |
| `measure` | `body?` | Volume (mm³), area (mm²), centre of mass, bbox and topology counts per body, with totals. |
| `body_topology` | `body` | Edges (index, midpoint, length, ends) and faces (index, area, centroid, normal). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. |
| `body_topology` | `body` | Edges (index, midpoint, length, ends) and faces (index, name, area, centroid, normal, `point`, and for a cylindrical face its `cylinder`: `origin`, `axis`, `radius`). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. A face's `point` lies on it and picks it (its centroid may not: that of a face with a hole is in the hole); a face's `name` picks it for the commands that take faces (`face.delete`, `solid.shell`, `solid.draft`, `solid.offset_face`, `solid.split_face`, `solid.replace_face`, and `face` of `sheet.convert`). |
| `set_parameter` | `name`, `value` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute; reports `recomputed` (features rebuilt), `restored` (features whose earlier result for the same inputs was reused, e.g. a value set back) and features that now fail. |
| `screenshot` | `view?` (iso, top, front, back, bottom, left, right, home, fit, or a named view saved with `view.save`), `camera?` (`{target, yaw, pitch, distance, fov?}`, as `view.apply`), `path?`, `width?`, `height?`, `source?` (window, model) | A PNG image content block. Headless: CPU render fitted to the model, or from the named view or camera. Bridged: the app window (default) or a model render with the app's camera; a named view or camera moves the app's viewport there first (`execute` `view.apply` does the same without a screenshot). |
| `export` | `path`, `format?` (step, 3mf, 3mf-tagged, stl, stla, obj, urdf), `bodies?`, `mesh_prefix?`, `effort?`, `velocity?` | STEP, 3MF or mesh export; URDF writes the whole assembly with its meshes next to it (`docs/urdf.md`). |
+1
View File
@@ -40,6 +40,7 @@ Work the part out as numbers first. Then build it in a few large `batch` calls,
- **Edge and face picks** snap to the nearest edge or face. There is no distance limit and no error, so compute the exact midpoint from your geometry (the vertical corner edge of a centred box is `[w/2, d/2, h/2]`).
- Picks must be plain numbers.
- A pick is resolved once and then follows its edge when parameters change.
- A face can be given by name instead of a point (`faces:["F1:+z"]`, the `face` of `sheet.convert`). `body_topology` lists each face's `name`, a `point` that lies on it (the centroid of a face with a hole is in the hole) and, for a cylinder, `{origin, axis, radius}`.
- **Profiles:** extrude takes every region by default. For a ring or a boss with a hole, pass `profiles`.
- **Order:** fillet the outside edges before you shell, so the shell follows the fillets. An edge of the face the shell removes, such as the rim of an open box, is the exception: round it after the shell, because the shell can fail on it.
+1 -1
View File
@@ -74,7 +74,7 @@ Distances are unsigned. Draw the geometry on the correct side first, and the dim
| `solid.cylinder` | `radius` or `diameter`, `height` | `base` is the centre of the bottom (default origin), `axis` defaults to `Z`. Expressions work in `base`. Always pass `operation`. |
| `sketch.slot.overall` / `sketch.slot.center_to_center` | `p0`, `p1` (overall ends / arc centres), `width` | Ids and dof are in the "Ids" section above. |
| `solid.fillet` / `solid.chamfer` | `edges:[[x,y,z]]`, `radius` / `distance` | The pick snaps to the **nearest** edge, with no distance limit and no error, so check that the face count changed. One pick takes the whole tangent chain: a rounded rim needs 1 point, not 8. |
| `solid.shell` | `faces:[[x,y,z]]`, `thickness` | A point on each face to remove. Fillet first, except an edge of a removed face (see Order in `SKILL.md`). |
| `solid.shell` | `faces:[[x,y,z]]`, `thickness` | A point on each face to remove, or its name from `body_topology`. Fillet first, except an edge of a removed face (see Order in `SKILL.md`). |
| `solid.hole` | `position:[x,y,z]` on a face + `diameter`, or `sketch` (+ `points`) | `clearance:"M3"`. `depth` defaults to through all. `type` is one of simple/drilled/counterbore/countersink. A `countersink` needs `cs_diameter` and `cs_angle`; a `counterbore` needs `cb_diameter` and `cb_depth`. `position` is a fixed point. |
| `solid.pattern.rectangular` | `features`, `dir1`, `count1`, `spacing1` (+ `dir2`, `count2`, `spacing2`) | Spacing takes expressions and follows parameters. |
| `solid.pattern.circular` | `features`, `axis` (`"Z"` or `{origin, dir}`), `count` | `angle` defaults to 360 deg. A full pattern keeps the COM on the axis, so check its radius with a top-view screenshot. |