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:
1 parent
37f4e4e49e
commit
840eddb875
9 files changed
+395
-24
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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, ®ion, &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)?);
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
@@ -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<_>>()}))
|
||||
}
|
||||
@@ -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
@@ -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, ®ion.outer.ccw())?;
|
||||
// The outer loop and each hole, swept along the path; the ends are capped with holes.
|
||||
let mut starts = vec![wire(plane, ®ion.outer.ccw())?];
|
||||
for h in ®ion.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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
Reference in new issue
Block a user