Pipe (solid or hollow, along sketch curves), Bounding Solid, Scale (uniform or per axis), Offset Face (planar faces), physical materials with mass in Measure; sweeps keep profile holes

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 23:00:57 +00:00
1 parent 37f4e4e49e
commit 840eddb875
9 files changed
+395 -24

No files matched your search

+91
View File
@@ -289,6 +289,41 @@ pub enum FeatureKind {
body: String,
plane: PlaneRef,
},
/// Scale bodies about a point (uniform, or per axis).
Scale {
bodies: Vec<String>,
#[serde(default)]
origin: Vec3,
factor: String,
/// Per-axis factors instead of `factor`.
#[serde(default, skip_serializing_if = "Option::is_none")]
factors: Option<[String; 3]>,
},
/// Move planar faces (at the given points) along their normals; the body keeps its shape.
OffsetFace {
faces: Vec<Vec3>,
distance: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
body: Option<String>,
},
/// A box around bodies, grown by a margin.
BoundingSolid {
bodies: Vec<String>,
#[serde(default = "zero_expr")]
margin: String,
},
/// A tube along a chain of sketch curves (solid, or hollow with a wall thickness).
Pipe {
path_sketch: u64,
path: Vec<String>,
diameter: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
wall: Option<String>,
#[serde(default)]
operation: Operation,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
targets: Vec<String>,
},
Move {
bodies: Vec<String>,
translate: [String; 3],
@@ -445,6 +480,10 @@ impl FeatureKind {
FeatureKind::Draft { .. } => "DraftFeature",
FeatureKind::Split { .. } => "SplitBodyFeature",
FeatureKind::Move { .. } => "MoveFeature",
FeatureKind::Scale { .. } => "ScaleFeature",
FeatureKind::OffsetFace { .. } => "OffsetFacesFeature",
FeatureKind::BoundingSolid { .. } => "BoundingSolidFeature",
FeatureKind::Pipe { .. } => "PipeFeature",
FeatureKind::Import { .. } => "BaseFeature",
FeatureKind::MeshImport { .. } => "MeshFeature",
}
@@ -474,6 +513,10 @@ impl FeatureKind {
FeatureKind::Draft { .. } => "Draft",
FeatureKind::Split { .. } => "Split",
FeatureKind::Move { .. } => "Move",
FeatureKind::Scale { .. } => "Scale",
FeatureKind::OffsetFace { .. } => "OffsetFace",
FeatureKind::BoundingSolid { .. } => "BoundingSolid",
FeatureKind::Pipe { .. } => "Pipe",
FeatureKind::Import { .. } => "Import",
FeatureKind::MeshImport { .. } => "Mesh",
}
@@ -531,6 +574,16 @@ impl FeatureKind {
}
}
FeatureKind::Thread { length, .. } => v.extend(length.iter().map(String::as_str)),
FeatureKind::Scale { factor, factors, .. } => {
v.push(factor);
v.extend(factors.iter().flatten().map(String::as_str));
}
FeatureKind::OffsetFace { distance, .. } => v.push(distance),
FeatureKind::BoundingSolid { margin, .. } => v.push(margin),
FeatureKind::Pipe { diameter, wall, .. } => {
v.push(diameter);
v.extend(wall.iter().map(String::as_str));
}
FeatureKind::Move { translate, angle, .. } => {
v.extend(translate.iter().map(String::as_str));
if let Some(a) = angle {
@@ -559,6 +612,10 @@ pub struct Feature {
pub kind: FeatureKind,
}
fn zero_expr() -> String {
"0".into()
}
fn is_root(c: &u64) -> bool {
*c == 0
}
@@ -581,6 +638,9 @@ pub struct Document {
/// Components below the root, as a tree (by parent).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub components: Vec<Component>,
/// Physical material per body (by body name); others use the default.
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub materials: std::collections::BTreeMap<String, String>,
/// Bodies moved into another component than their feature's (by body name).
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub body_components: std::collections::BTreeMap<String, u64>,
@@ -611,9 +671,15 @@ impl Document {
next_id: 1,
components: Vec::new(),
body_components: Default::default(),
materials: Default::default(),
}
}
/// Density of a body's material (g/cm³).
pub fn density(&self, body: &str) -> f64 {
self.materials.get(body).and_then(|m| material_density(m)).unwrap_or(DEFAULT_DENSITY)
}
/// The component a body belongs to: where it was moved, else its feature's.
pub fn body_component(&self, body: &str, feature: u64) -> u64 {
self.body_components.get(body).copied().unwrap_or_else(|| self.feature(feature).map(|f| f.component).unwrap_or(0))
@@ -910,3 +976,28 @@ impl Document {
Ok(())
}
}
/// Density of the default material (g/cm³), as Fusion's default.
pub const DEFAULT_DENSITY: f64 = 1.29;
/// Physical materials: name and density (g/cm³).
pub const MATERIALS: [(&str, f64); 14] = [
("Default", DEFAULT_DENSITY),
("Steel", 7.85),
("Stainless Steel", 8.0),
("Aluminum", 2.70),
("Brass", 8.50),
("Copper", 8.96),
("Titanium", 4.43),
("Cast Iron", 7.20),
("ABS Plastic", 1.06),
("PLA", 1.24),
("Nylon", 1.14),
("Polycarbonate", 1.20),
("Oak", 0.75),
("Glass", 2.50),
];
pub fn material_density(name: &str) -> Option<f64> {
MATERIALS.iter().find(|(n, _)| n.eq_ignore_ascii_case(name.trim())).map(|(_, d)| *d)
}
+90
View File
@@ -940,6 +940,34 @@ fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -
let segs = path_segments(ps, path, start)?;
regions.iter().map(|r| kernel::sweep(&ss.plane, r, &segs).map_err(DocError::from)).collect()
}
FeatureKind::Pipe { path_sketch, path, diameter, wall, .. } => {
let ps =
st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?;
let r = val(vals, diameter, Kind::Length)? / 2.0;
if !(r > 1e-6) {
return Err(DocError::Invalid("pipe diameter must be positive".into()));
}
let segs = path_segments(ps, path, Vec3::new(f64::NAN, f64::NAN, f64::NAN))?;
let first = segs.first().ok_or_else(|| DocError::Invalid("empty path".into()))?;
let (start, tangent) = match *first {
kernel::PathSeg::Line { a, b } => (a, b - a),
kernel::PathSeg::Arc { a, center, axis, .. } => (a, axis.cross(a - center)),
};
let t = tangent.normalized().ok_or_else(|| DocError::Invalid("path direction".into()))?;
let plane = Plane::from_normal(start, t).ok_or_else(|| DocError::Invalid("pipe profile plane".into()))?;
let holes = match wall {
Some(w) => {
let wt = val(vals, w, Kind::Length)?;
if !(wt > 0.0 && wt < r) {
return Err(DocError::Invalid("the wall must be thinner than the radius".into()));
}
vec![solvecraft_geom::Loop2::circle(Vec2::ZERO, r - wt).reversed()]
}
None => Vec::new(),
};
let region = Region2 { outer: solvecraft_geom::Loop2::circle(Vec2::ZERO, r), holes };
Ok(vec![kernel::sweep(&plane, &region, &segs)?])
}
FeatureKind::Box { corner, length, width, height, .. } => {
let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?);
Ok(vec![kernel::box_solid(*corner, *corner + s)?])
@@ -985,6 +1013,13 @@ fn path_segments(ps: &SolvedSketch, ids: &[String], start: Vec3) -> Result<Vec<k
{
segs[0] = f.reversed();
}
// No start given (a pipe): the first curve runs toward the second.
if !start.is_finite()
&& let (Some(f), Some(n)) = (segs.first().copied(), segs.get(1).copied())
&& (n.start().dist(f.start()) < 1e-6 || n.end().dist(f.start()) < 1e-6)
{
segs[0] = f.reversed();
}
for i in 1..segs.len() {
let prev_end = segs[i - 1].end();
if segs[i].end().dist(prev_end) < segs[i].start().dist(prev_end) {
@@ -1015,6 +1050,7 @@ fn feature_op(f: &Feature) -> (Operation, Vec<String>) {
| FeatureKind::Sphere { operation, .. }
| FeatureKind::Torus { operation, .. } => (*operation, Vec::new()),
FeatureKind::Hole { .. } => (Operation::Cut, Vec::new()),
FeatureKind::Pipe { operation, targets, .. } => (*operation, targets.clone()),
FeatureKind::Loft { operation, targets, .. } | FeatureKind::Sweep { operation, targets, .. } => (*operation, targets.clone()),
_ => (Operation::NewBody, Vec::new()),
}
@@ -1156,6 +1192,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
Ok(())
}
FeatureKind::Extrude { operation, targets, .. }
| FeatureKind::Pipe { operation, targets, .. }
| FeatureKind::Revolve { operation, targets, .. }
| FeatureKind::Loft { operation, targets, .. }
| FeatureKind::Sweep { operation, targets, .. } => {
@@ -1291,6 +1328,59 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
}
Ok(())
}
FeatureKind::Scale { bodies, origin, factor, factors } => {
let k = match factors {
Some(fs) => [val(vals, &fs[0], Kind::Unitless)?, val(vals, &fs[1], Kind::Unitless)?, val(vals, &fs[2], Kind::Unitless)?],
None => {
let x = val(vals, factor, Kind::Unitless)?;
[x, x, x]
}
};
if k.iter().any(|x| !(x.is_finite() && *x > 1e-9 && *x < 1e6)) {
return Err(DocError::Invalid("scale factors must be positive".into()));
}
let o = *origin;
// Column-major: scale about the origin.
let m = [
[k[0], 0.0, 0.0, 0.0],
[0.0, k[1], 0.0, 0.0],
[0.0, 0.0, k[2], 0.0],
[o.x * (1.0 - k[0]), o.y * (1.0 - k[1]), o.z * (1.0 - k[2]), 1.0],
];
for n in bodies {
let i = st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
if let Some(mb) = st.bodies.get_mut(i) {
let nb = kernel::transform_matrix(&mb.body, m)?;
*mb = ModelBody::new(mb.name.clone(), nb, mb.feature);
}
}
Ok(())
}
FeatureKind::OffsetFace { faces, distance, body } => {
let d = val(vals, distance, Kind::Length)?;
let i = body_at(st, body, faces)?;
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
let nb = kernel::offset_faces(&mb.body, faces, d)?;
if let Some(slot) = st.bodies.get_mut(i) {
*slot = ModelBody::new(mb.name, nb, mb.feature);
}
Ok(())
}
FeatureKind::BoundingSolid { bodies, margin } => {
let m = val(vals, margin, Kind::Length)?;
let mut bb = solvecraft_geom::Aabb3::EMPTY;
for b in st.bodies.iter().filter(|b| bodies.is_empty() || bodies.contains(&b.name)) {
bb = bb.union(&b.mesh().bounds());
}
if bb.is_empty() {
return Err(DocError::Invalid("no bodies to bound".into()));
}
let pad = Vec3::new(m, m, m);
let b = kernel::box_solid(bb.min - pad, bb.max + pad)?;
let name = unique_body_name(st, f.body_names.first().map(String::as_str).unwrap_or("Bounding"));
st.bodies.push(ModelBody::new(name, b, f.id));
Ok(())
}
FeatureKind::Move { bodies, translate, rotate_axis, angle } => {
let t =
Vec3::new(val(vals, &translate[0], Kind::Length)?, val(vals, &translate[1], Kind::Length)?, val(vals, &translate[2], Kind::Length)?);
+74
View File
@@ -48,6 +48,22 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("hole")
.key("H")
.params("position: [x,y,z] on a face | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch); direction?: [x,y,z] (default: into the face); diameter; depth? (default through all); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"),
CommandSpec::new("PrimitivePipe", "Pipe", pipe)
.at("SOLID", "CREATE")
.icon("pipe")
.params("path_sketch: sketch; path: [curve ids in order]; diameter: expr; wall?: expr (hollow); operation?, targets?, name?, body_name?"),
CommandSpec::new("StockModelCommand", "Bounding Solid", bounding_solid)
.at("SOLID", "CREATE")
.icon("box")
.params("bodies?: [names] (default: all); margin?: expr; name?, body_name?"),
CommandSpec::new("ModifyScale", "Scale", scale)
.at("SOLID", "MODIFY")
.icon("scale")
.params("bodies: [names]; factor: expr | factors: [x, y, z] exprs; origin?: [x,y,z]"),
CommandSpec::new("FusionOffsetFacesCommand", "Offset Face", offset_face)
.at("SOLID", "MODIFY")
.icon("offset_face")
.params("faces: [[x,y,z] points on planar faces]; distance: expr (positive: outward); body?"),
CommandSpec::new("FusionThreadCommand", "Thread", thread)
.at("SOLID", "CREATE")
.icon("thread")
@@ -783,3 +799,61 @@ fn thread(s: &mut Session, p: &Value) -> Result<Value> {
let t = v.get("feature").and_then(Value::as_u64).and_then(|id| st.threads.iter().find(|t| t.feature == id).cloned());
Ok(json!({"feature": v.get("feature"), "name": v.get("name"), "thread": t}))
}
fn pipe(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "PrimitivePipe";
let path_sketch = sketch_id(s, p.get("path_sketch"), cmd, "path_sketch")?;
let path = string_list(p, "path");
if path.is_empty() {
return Err(bad(cmd, "`path` must list curve ids"));
}
let diameter = req_expr(cmd, p, "diameter")?;
check_expr(s, &diameter, Kind::Length, cmd, "diameter")?;
let wall = expr(p, "wall");
if let Some(w) = &wall {
check_expr(s, w, Kind::Length, cmd, "wall")?;
}
add_feature(s, p, FeatureKind::Pipe { path_sketch, path, diameter, wall, operation: operation(p, cmd)?, targets: string_list(p, "targets") })
}
fn bounding_solid(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "StockModelCommand";
let margin = expr(p, "margin").unwrap_or_else(|| "0".into());
check_expr(s, &margin, Kind::Length, cmd, "margin")?;
add_feature(s, p, FeatureKind::BoundingSolid { bodies: string_list(p, "bodies"), margin })
}
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ModifyScale";
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
return Err(bad(cmd, "`bodies` must list bodies"));
}
let factors = match p.get("factors").and_then(Value::as_array) {
Some(a) if a.len() == 3 => {
let e: Vec<String> = a.iter().map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())).collect();
for x in &e {
check_expr(s, x, Kind::Unitless, cmd, "factors")?;
}
Some([e[0].clone(), e[1].clone(), e[2].clone()])
}
Some(_) => return Err(bad(cmd, "`factors` must have three values")),
None => None,
};
let factor = match (&factors, expr(p, "factor")) {
(_, Some(f)) => f,
(Some(_), None) => "1".into(),
(None, None) => return Err(bad(cmd, "give `factor` or `factors`")),
};
check_expr(s, &factor, Kind::Unitless, cmd, "factor")?;
let origin = p.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
add_feature(s, p, FeatureKind::Scale { bodies, origin, factor, factors })
}
fn offset_face(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "FusionOffsetFacesCommand";
let faces = face_points(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) })
}
+47 -2
View File
@@ -19,6 +19,11 @@ pub static COMMANDS: &[CommandSpec] = &[
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.threads", "List Threads", model_threads).noundo(),
CommandSpec::new("PhysicalMaterialCommand", "Physical Material", physical_material)
.at("SOLID", "MODIFY")
.icon("material")
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …; `material.list`)"),
CommandSpec::new("material.list", "List Materials", material_list).noundo(),
CommandSpec::new("engine.commands", "List Commands", commands).noundo(),
];
@@ -43,7 +48,17 @@ pub fn measure_json(b: &solvecraft_doc::ModelBody) -> Value {
fn measure(s: &mut Session, p: &Value) -> Result<Value> {
let want = string_list(p, "bodies");
let st = s.model.state();
let bodies: Vec<Value> = st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).map(measure_json).collect();
let mut bodies: Vec<Value> = st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).map(measure_json).collect();
// Mass from each body's material.
for b in &mut bodies {
let name = b["name"].as_str().unwrap_or_default().to_string();
if let Some(v) = b["volume_mm3"].as_f64() {
let rho = s.doc.density(&name);
b["material"] = json!(s.doc.materials.get(&name).cloned().unwrap_or_else(|| "Default".into()));
b["density_g_cm3"] = json!(rho);
b["mass_g"] = json!(v * rho / 1000.0);
}
}
if !want.is_empty() && bodies.len() != want.len() {
return Err(bad("MeasureCommand", "unknown body name"));
}
@@ -52,7 +67,10 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
let tf: u64 = bodies.iter().filter_map(|b| b["faces"].as_u64()).sum();
let te: u64 = bodies.iter().filter_map(|b| b["edges"].as_u64()).sum();
let tvx: u64 = bodies.iter().filter_map(|b| b["vertices"].as_u64()).sum();
Ok(json!({"body_count": bodies.len(), "bodies": bodies, "total": {"volume_mm3": tv, "area_mm2": ta, "faces": tf, "edges": te, "vertices": tvx}}))
let tm: f64 = bodies.iter().filter_map(|b| b["mass_g"].as_f64()).sum();
Ok(
json!({"body_count": bodies.len(), "bodies": bodies, "total": {"volume_mm3": tv, "area_mm2": ta, "mass_g": tm, "faces": tf, "edges": te, "vertices": tvx}}),
)
}
fn sketch_json(s: &Session, id: u64) -> Option<Value> {
@@ -212,3 +230,30 @@ fn model_threads(s: &mut Session, _p: &Value) -> Result<Value> {
let st = s.model.state();
Ok(json!({ "threads": st.threads }))
}
fn physical_material(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "PhysicalMaterialCommand";
let bodies = string_list(p, "bodies");
let material = str_(p, "material").ok_or_else(|| bad(cmd, "`material` must be a material name"))?;
let canonical = solvecraft_doc::MATERIALS
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(material.trim()))
.map(|(n, _)| n.to_string())
.ok_or_else(|| bad(cmd, format!("unknown material `{material}` (see material.list)")))?;
let st = s.model.state();
if bodies.is_empty() || bodies.iter().any(|b| st.body(b).is_none()) {
return Err(bad(cmd, "`bodies` must list existing bodies"));
}
for b in &bodies {
if canonical == "Default" {
s.doc_mut().materials.remove(b);
} else {
s.doc_mut().materials.insert(b.clone(), canonical.clone());
}
}
Ok(json!({"bodies": bodies, "material": canonical}))
}
fn material_list(_s: &mut Session, _p: &Value) -> Result<Value> {
Ok(json!({"materials": solvecraft_doc::MATERIALS.iter().map(|(n, d)| json!({"name": n, "density_g_cm3": d})).collect::<Vec<_>>()}))
}
+33
View File
@@ -697,3 +697,36 @@ fn holes_at_sketch_points_threads_and_components() {
assert_eq!(back.components.len(), 2);
assert_eq!(back.body_components.len(), 1);
}
#[test]
fn pipe_scale_offset_bounding_and_materials() {
let mut s = Session::default();
// Pipe along an L path (line, arc, line) on XZ.
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "Path"}));
run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [30, 0]], "ids": ["l1"]}));
run(&mut s, "ArcCenterTwoPoint", json!({"center": [30, 10], "start": "l1.end", "end": [40, 10], "id": "a1"}));
run(&mut s, "DrawPolyline", json!({"points": ["a1.end", [40, 40]], "ids": ["l2"]}));
run(&mut s, "SketchStop", json!({}));
run(&mut s, "PrimitivePipe", json!({"path_sketch": "Path", "path": ["l1", "a1", "l2"], "diameter": 4, "wall": 1, "body_name": "Tube"}));
let len = 30.0 + PI * 10.0 / 2.0 + 30.0;
let v = volume(&mut s);
assert!(rel(v, PI * (4.0 - 1.0) * len) < 2e-3, "{v} vs {}", PI * 3.0 * len);
// Bounding solid around it; then scale a box and push one of its faces.
run(&mut s, "StockModelCommand", json!({"bodies": ["Tube"], "margin": 1, "body_name": "Stock"}));
let m = run(&mut s, "MeasureCommand", json!({"bodies": ["Stock"]}));
let bb = &m["bodies"][0]["bbox"];
assert!((bb["min"][1].as_f64().unwrap() + 3.0).abs() < 1e-6 && (bb["max"][1].as_f64().unwrap() - 41.0).abs() < 1e-6, "{bb}");
run(&mut s, "PrimitiveBox", json!({"corner": [100, 0, 0], "length": 10, "width": 10, "height": 10, "body_name": "Cube"}));
run(&mut s, "ModifyScale", json!({"bodies": ["Cube"], "factor": 2, "origin": [100, 0, 0]}));
let vc = run(&mut s, "MeasureCommand", json!({"bodies": ["Cube"]}))["total"]["volume_mm3"].as_f64().unwrap();
assert!(rel(vc, 8000.0) < 1e-9, "{vc}");
run(&mut s, "FusionOffsetFacesCommand", json!({"faces": [[110, 10, 20]], "distance": 5}));
let vc = run(&mut s, "MeasureCommand", json!({"bodies": ["Cube"]}))["total"]["volume_mm3"].as_f64().unwrap();
assert!(rel(vc, 20.0 * 20.0 * 25.0) < 1e-9, "{vc}");
// Materials and mass.
run(&mut s, "PhysicalMaterialCommand", json!({"bodies": ["Cube"], "material": "aluminum"}));
let m = run(&mut s, "MeasureCommand", json!({"bodies": ["Cube"]}));
assert!((m["bodies"][0]["mass_g"].as_f64().unwrap() - 10_000.0 * 2.7 / 1000.0).abs() < 1e-6, "{m}");
assert_eq!(m["bodies"][0]["material"], "Aluminum");
assert!(s.execute("PhysicalMaterialCommand", &json!({"bodies": ["Cube"], "material": "unobtainium"})).is_err());
}
+26 -18
View File
@@ -554,28 +554,36 @@ pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body>
if path.is_empty() || path.len() > 1000 {
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
}
let w0 = wire(plane, &region.outer.ccw())?;
// The outer loop and each hole, swept along the path; the ends are capped with holes.
let mut starts = vec![wire(plane, &region.outer.ccw())?];
for h in &region.holes {
starts.push(wire(plane, &h.ccw().reversed())?);
}
guard("sweep", || {
let mut shell = mt::Shell::new();
let mut cur = w0.clone();
for seg in path {
let mut part: mt::Shell = match *seg {
PathSeg::Line { a, b } => builder::tsweep(&cur, v3(b - a)),
PathSeg::Arc { center, axis, angle, .. } => {
let ax = axis.normalized().ok_or_else(|| KernelError::Invalid("arc axis".into()))?;
builder::rsweep(&cur, p3(center), v3(ax), mt::Rad(angle))
}
};
let next = part
.extract_boundaries()
.into_iter()
.find(|w| w.edge_iter().all(|e| !cur.edge_iter().any(|c| c.id() == e.id())))
.ok_or_else(|| KernelError::Failed("sweep: lost the profile".into()))?;
shell.append(&mut part);
cur = next.inverse();
let mut ends = Vec::new();
for w0 in &starts {
let mut cur = w0.clone();
for seg in path {
let mut part: mt::Shell = match *seg {
PathSeg::Line { a, b } => builder::tsweep(&cur, v3(b - a)),
PathSeg::Arc { center, axis, angle, .. } => {
let ax = axis.normalized().ok_or_else(|| KernelError::Invalid("arc axis".into()))?;
builder::rsweep(&cur, p3(center), v3(ax), mt::Rad(angle))
}
};
let next = part
.extract_boundaries()
.into_iter()
.find(|w| w.edge_iter().all(|e| !cur.edge_iter().any(|c| c.id() == e.id())))
.ok_or_else(|| KernelError::Failed("sweep: lost the profile".into()))?;
shell.append(&mut part);
cur = next.inverse();
}
ends.push(cur);
}
let faces: Vec<mt::Face> = shell.face_iter().cloned().collect();
cap_and_close(faces, std::slice::from_ref(&w0), std::slice::from_ref(&cur))
cap_and_close(faces, &starts, &ends)
})
}
+1 -1
View File
@@ -34,7 +34,7 @@ pub use measure::{BodyMeasure, measure};
pub use meshbody::{MAX_TRIANGLES, mesh_body};
pub use ops::{BoolOp, boolean, split_by_plane, transform, transform_matrix};
pub use polybool::planar_boolean;
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, shell};
pub use polyhedron::{HalfSpace, convex_polyhedron, draft, offset_faces, shell};
pub use step::step_export;
pub use step_in::{ImportedBody, StepImport, StepNode, step_import, step_import_shared};
pub use topo::{CylinderFace, cylinder_face_at};
+3 -3
View File
@@ -69,15 +69,15 @@ fn mismatch(a: &solvecraft_geom::Mesh, b: &solvecraft_geom::Mesh, result: &solve
Some(bad as f64 / n.max(1) as f64)
}
/// Boolean of two bodies. The result may be empty (`Ok(None)`) for a cut that removes
/// everything or an intersection of disjoint bodies. Results are checked against volume bounds
/// and retried with shifted copies and other tolerances when they fail or look wrong.
thread_local! {
/// Set while a boolean runs on bodies whose coincident faces were pushed apart (no
/// second round of pushing).
static APART: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Boolean of two bodies. The result may be empty (`Ok(None)`) for a cut that removes
/// everything or an intersection of disjoint bodies. Results are checked against volume bounds
/// and retried with shifted copies and other tolerances when they fail or look wrong.
pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
a.require_brep("a boolean")?;
b.require_brep("a boolean")?;
+30
View File
@@ -460,3 +460,33 @@ fn solve3(m: [[f64; 3]; 3], r: [f64; 3]) -> Option<[f64; 3]> {
}
Some(out)
}
/// Move the planar faces at the given points along their outward normals by `distance`
/// (negative: into the body). The body's other faces follow (its topology is kept).
pub fn offset_faces(b: &Body, at: &[Vec3], distance: f64) -> Result<Body> {
b.require_brep("offset faces")?;
if !distance.is_finite() || distance.abs() > 1e6 {
return Err(KernelError::Invalid("offset distance".into()));
}
let healed = Body::new(crate::heal::heal(b.deep_copy(), b.size()))?;
let size = healed.size();
let mesh = healed.tessellate((size * 1e-3).max(1e-3))?;
let mut chosen: Vec<usize> = Vec::new();
for p in at {
let near = mesh
.triangles
.iter()
.zip(&mesh.tri_face)
.filter_map(|(t, f)| mesh.tri(t).map(|[x, y, z]| (point_tri(*p, x, y, z), *f as usize)))
.min_by(|a, c| a.0.total_cmp(&c.0));
match near {
Some((d, f)) if d < size * 1e-3 + 1e-6 => {
if !chosen.contains(&f) {
chosen.push(f);
}
}
_ => return Err(KernelError::Invalid(format!("no face at {:?}", [p.x, p.y, p.z]))),
}
}
offset_planar(&healed, |fi, _| if chosen.contains(&fi) { distance } else { 0.0 })
}