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

+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;