Faces by name in the face commands; model.faces gives a point on each face (#1243)
This commit is contained in:
1 parent
2cab09f65b
commit
5c6f70cc00
17 files changed
+480
-29
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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) })
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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}))
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user