Files
solidcraft-upstream/crates/engine/src/cmd/features.rs
T

2152 lines
109 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Solid feature commands: extrude, revolve, fillet, chamfer, primitives, combine, move.
use serde_json::{Value, json};
use solidcraft_doc::{
AxisRef, Direction, Expr, Extent, FeatureKind, Operation, PointExpr, ProfileSel, SweepOrientation, ThinSide, ThinWall, expr::Kind, point_expr,
};
use solidcraft_doc::{BodyName, FeatureName, SketchId};
use solidcraft_doc::{Dir, FacePick, Position};
use solidcraft_geom::Vec3;
use super::{CommandSpec, not_in_sketch};
use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec2, vec3};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("solid.extrude", "Extrude", extrude)
.at("SOLID", "CREATE")
.icon("extrude")
.key("E")
.params("distance: expr (or through_all: true); taper?: angle expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [{curves: [ids]}] (the profile whose outer loop is made of exactly these curves, or pieces of them; found again on every recompute, so it follows parameter changes; islands inside are its holes; an error when no profile or several match) | [{point:[x,y]}] (index and point picks are stored by their curves when that finds the same profile); face?: [x,y,z] (extrude a planar body face instead of a sketch profile); face_body?: name (whose face, where faces of several bodies meet at the point); direction?: positive|negative|symmetric (distance is total thickness, split evenly around the start plane); convention?: total_distance|legacy_per_side (default total_distance; legacy preserves older symmetric extents); distance2?; start_offset?: expr (along the sketch normal, also with direction: negative); to?: [x,y,z] (To Object: a face, vertex or point; to_offset?: expr past it); from?: [x,y,z] (From Object: start at that face or point); operation?: new|join|cut|intersect|auto (cut into a body, otherwise join existing solids; new in an empty component); targets?: [body] (Join from a face, or from a sketch on a body's face, defaults to that face's body; explicit targets or participants override); name?; body_name?; thin?: true (Thin: a wall along sketch curves instead of the profiles) with curves?: [curve ids] (open chains and closed loops, joined where they meet end to end; default: every drawn curve of the sketch), thickness: expr, side?: center|inside|outside|two_sides (a closed loop's inside is the area it encloses, an open chain's the side it bends toward, a straight line's the left of its first curve as drawn; default center), thickness2?: expr (two_sides: the thickness outside); corners are mitred, crossing walls are one"),
CommandSpec::new("solid.revolve", "Revolve", revolve)
.at("SOLID", "CREATE")
.icon("revolve")
.params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); angle2?: expr (Two Sides: the other side's angle); direction?: symmetric (angle split either side, independent of axis direction); convention?: sketch_normal|legacy_axis_start (default sketch_normal; legacy retains older angular starts); to?: [x,y,z] (To Object: turn until that face, vertex or point); sketch?, profiles? | face: [x,y,z] (a planar body face as the profile) and face_body?: name (whose face, where faces of several bodies meet), operation?, targets?, name?, body_name?"),
CommandSpec::new("solid.sweep", "Sweep", sweep)
.at("SOLID", "CREATE")
.icon("sweep")
.params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; orientation?: perpendicular (default: square to the path, turning with it) | parallel (as drawn: the profile moves without turning; the path must keep crossing its plane) | [x,y,z] (square to the path, its turn about the path fixed by this direction); twist?: angle expr (the profile turns this much about the path over its length; within 4 turns); scale?: expr (the profile's scale at the end, linear along the path; 0.01…100); operation?, targets?, name?, body_name?"),
CommandSpec::new("solid.loft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?} | {sketch, point: id} (an apex)] (2 or more, in order); operation?, targets?, name?, body_name?"),
CommandSpec::new("solid.box", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"),
CommandSpec::new("solid.cylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z] (centre of the bottom: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?, targets?: [body], name?, body_name?"),
CommandSpec::new("solid.sphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"),
CommandSpec::new("solid.torus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?: [x,y,z] (numbers in mm or length expressions; default: the origin); operation?, targets?: [body], name?, body_name?"),
CommandSpec::new("solid.fillet", "Fillet", fillet)
.at("SOLID", "MODIFY")
.icon("fillet")
.key("F")
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one perpendicular planar end face at each end; radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; g2: a patch repeating each blend's surface up to the second order, constant-radius exact fillets only, refused where it cannot be built); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"),
CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect)
.at("SOLID", "CREATE")
.icon("pattern_rect")
.params("features: [names] | bodies: [names] | components: [names]; dir1: [x,y,z]; count1; spacing1; dir2?, count2?, spacing2?; alignment?, alignment2?: left|center|right (default right; counts include the original); reversed?, reversed2?: bool (Flip)"),
CommandSpec::new("solid.pattern.circular", "Circular Pattern", pattern_circ)
.at("SOLID", "CREATE")
.icon("pattern_circ")
.params("features: [names] | bodies: [names] | components: [names]; axis: X|Y|Z | {origin, dir}; count; angle? (default 360 deg; partial arcs include both ends); alignment?: left|center|right (default right; original stays fixed); reversed?: bool (Flip; selects the extra side for even centered counts)"),
CommandSpec::new("solid.pattern.path", "Pattern on Path", pattern_path)
.at("SOLID", "CREATE")
.icon("pattern_rect")
.params("features: [names] | bodies: [names]; path_sketch: sketch; path: [curve ids in order]; count; spacing (between instances) | distance (first to last); orientation?: identical|path; flip?: bool"),
CommandSpec::new("solid.mirror", "Mirror", mirror).at("SOLID", "CREATE").icon("mirror").params("features: [names] | bodies: [names] (combine?: join with the original); plane: XY|XZ|YZ|construction plane name or id|{origin, normal}|{origin, x_dir, y_dir} (origin coordinates may be length expressions; the mirror follows them, or a construction plane)"),
CommandSpec::new("solid.hole", "Hole", hole)
.at("SOLID", "CREATE")
.icon("hole")
.key("H")
.params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic)"),
CommandSpec::new("solid.pipe", "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("solid.bounding_solid", "Bounding Solid", bounding_solid)
.at("SOLID", "CREATE")
.icon("box")
.params("bodies?: [names] (default: all); margin?: expr; name?, body_name?"),
CommandSpec::new("solid.scale", "Scale", scale)
.at("SOLID", "MODIFY")
.icon("scale")
.params("bodies: [names]; factor: expr | factors: [x, y, z] exprs; origin?: [x,y,z]"),
CommandSpec::new("face.delete", "Delete Face", delete_face)
.at("SOLID", "MODIFY")
.icon("delete")
.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, or face names from model.faces]; distance: expr (positive: outward); body?"),
CommandSpec::new("solid.thread", "Thread", thread)
.at("SOLID", "CREATE")
.icon("thread")
.params("face: [x,y,z] on a cylindrical face; designation?: ISO metric (\"M8\", \"M8x1\"; default: the size that fits); length?: expr (default: the whole face). Cosmetic: the model is unchanged, model.threads lists it"),
CommandSpec::new("solid.shell", "Shell", shell)
.at("SOLID", "MODIFY")
.icon("shell")
.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] 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")
.params("base: XY|XZ|YZ|plane name, or a planar face {face: [x,y,z], body?, index?, path?}; offset: expr; name?"),
CommandSpec::new("construct.plane.at_angle", "Plane at Angle", plane_angle)
.at("SOLID", "CONSTRUCT")
.icon("plane")
.params("base: XY|XZ|YZ|plane name; axis: X|Y|Z|{origin, dir}; angle: expr; name?"),
CommandSpec::new("solid.split_face", "Split Face", split_face)
.at("SOLID", "MODIFY")
.icon("split")
.params(
"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")
.icon("split")
.params("body: name; plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id, line: curve id (a straight sketch line, along the sketch's normal)"),
CommandSpec::new("solid.press_pull", "Press Pull", press_pull)
.at("SOLID", "MODIFY")
.icon("presspull")
.key("Q")
.params("face: [x,y,z] and distance: expr (positive adds material, negative cuts), or edges: [[x,y,z]…] and distance (a fillet)"),
CommandSpec::new("solid.move", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").enabled(not_in_sketch).params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr; copy?: bool (default false: keeps the original bodies and transforms a new copy instead of moving them in place)"),
];
/// Where an extrude starts and which way is positive: a point inside the (first) profile or
/// face, and the unit normal.
fn extrude_base(s: &Session, p: &Value) -> Option<(Vec3, Vec3)> {
if let Some(fp) = p.get("face").and_then(vec3) {
return Some((fp, super::face::face_normal_in(s, fp, str_(p, "face_body"))?));
}
let sid = feature_sketch(s, p, "Extrude").ok()?;
let st = s.model.state();
let ss = st.sketch(sid)?;
if bool_(p, "thin").unwrap_or(false) {
let thin = thin_param(s, p, sid, "solid.extrude").ok()?;
let q = thin_regions(s, ss, &thin).ok()?.first()?.interior_point();
return Some((ss.plane.to_world(q), ss.plane.normal()));
}
let ps = &ss.profiles;
let q = match profiles(p, "Extrude").ok()? {
ProfileSel::All => ps.first()?.region.interior_point(),
ProfileSel::Indices { indices } => ps.get(*indices.first()?)?.region.interior_point(),
ProfileSel::Points { points } => *points.first()?,
selection @ (ProfileSel::Curves { .. } | ProfileSel::OffsetBoundaries { .. } | ProfileSel::WholeBoundaries { .. }) => {
let indices = solidcraft_doc::profile_selection_indices(ss, &selection).ok()?;
ps.get(*indices.first()?)?.region.interior_point()
}
};
Some((ss.plane.to_world(q), ss.plane.normal()))
}
/// How far an extrude with these parameters goes from its profile along the profile's normal
/// (negative: the other way). To an object, the target's height, on whichever side it is: the
/// extrude goes there and its `direction` does not count (#1633). Otherwise its distance, turned
/// round for the negative direction.
pub fn extrude_reach(s: &Session, p: &Value) -> Option<f64> {
if let Some(t) = p.get("to").and_then(vec3) {
let (base, n) = extrude_base(s, p)?;
return Some((t - base).dot(n)).filter(|h| h.is_finite());
}
let d = s.doc.eval(&expr(p, "distance")?, Kind::Length).ok()?;
let dir = str_(p, "direction").map(str::to_ascii_lowercase);
Some(if matches!(dir.as_deref(), Some("negative" | "reverse" | "flip")) { -d } else { d })
}
/// How far past the profile [`auto_operation`] looks for material it starts in.
const START_PROBE_MM: f64 = 0.1;
/// What `operation: "auto"` means for an extrude with these parameters: `cut` when it goes
/// into a body, otherwise `join` when the active component has a solid. Keeping an
/// addition separate requires an explicit `new` operation.
///
/// "Goes into a body" is decided where the extrude starts: just past the profile in the
/// direction it grows, or halfway along it. With the halfway point alone, a cut dragged far
/// past a body's far side read as a Join (#1257).
pub fn auto_operation(s: &Session, p: &Value) -> Option<&'static str> {
let d = extrude_reach(s, p)?;
let symmetric = p.get("to").is_none() && str_(p, "direction").is_some_and(|d| d.eq_ignore_ascii_case("symmetric"));
let (base, n) = extrude_base(s, p)?;
let st = s.model.state();
if st.bodies.is_empty() || !d.is_finite() {
return Some("new");
}
// Only the active component's bodies take part (or the ones named as participants).
let parts = string_list(p, "participants");
let bodies: Vec<_> = st
.bodies
.iter()
.filter(|b| if parts.is_empty() { s.doc.model_body_component(b) == s.active_component } else { parts.contains(&b.name) })
.collect();
if bodies.is_empty() {
return Some("new");
}
let inside = |q: Vec3| bodies.iter().any(|b| b.mesh().contains(q));
// A step into whatever is right in front of the profile, short of the far end.
let near = |d: f64| d.signum() * (d.abs() * 0.5).min(START_PROBE_MM);
let into = |d: f64| inside(base + n * near(d)) || inside(base + n * (d * 0.5));
if symmetric {
return Some(if into(d.abs()) || into(-d.abs()) { "cut" } else { "join" });
}
if into(d) {
Some("cut")
} else {
// A profile can meet material along its side, or only around part of a
// screw boss. A single point behind its centre cannot establish contact.
// Default additions to Join and let the boolean merge the actual geometry.
Some("join")
}
}
pub(super) fn operation(p: &Value, cmd: &str) -> Result<Operation> {
match str_(p, "operation") {
None => Ok(Operation::NewBody),
Some(o) => Operation::parse(o).ok_or_else(|| bad(cmd, format!("unknown operation `{o}` (new, join, cut, intersect)"))),
}
}
/// The sketch a feature uses: `sketch` param, the active sketch, or the last sketch.
pub(super) fn feature_sketch(s: &Session, p: &Value, cmd: &str) -> Result<u64> {
if let Some(v) = p.get("sketch") {
let key = match v {
Value::Number(n) => n.to_string(),
Value::String(x) => x.clone(),
_ => return Err(bad(cmd, "`sketch` must be an id or name")),
};
let f = s.doc.find_feature(&key).ok_or_else(|| bad(cmd, format!("no sketch `{key}`")))?;
return match f.kind {
FeatureKind::Sketch { .. } => Ok(f.id),
_ => Err(bad(cmd, format!("`{key}` is not a sketch"))),
};
}
if let Some(a) = s.active_sketch {
return Ok(a);
}
let limit = s.doc.marker.unwrap_or(usize::MAX);
s.doc
.features
.iter()
.take(limit)
.rev()
.find(|f| matches!(f.kind, FeatureKind::Sketch { .. }))
.map(|f| f.id)
.ok_or_else(|| bad(cmd, "there is no sketch to use (create one first)"))
}
/// Profiles picked by index or point are stored by the curve ids of each profile's outer loop
/// as the sketch is now solved, so they follow the geometry when parameters move it. A pick
/// whose curves would not find that same profile again (two profiles bounded by the same
/// curves) stays as given. Curve picks are checked: one that names no profile, or several, is
/// refused now rather than failing later.
pub(super) fn stable_profiles(s: &Session, kind: &mut FeatureKind) -> Result<()> {
use solidcraft_doc::sketch::profile_at_point;
let st = s.model.state();
for (sketch, sel) in kind.profile_sels_mut() {
let Some(ss) = st.sketch(sketch) else { continue };
let indices = match sel {
ProfileSel::Indices { .. } => solidcraft_doc::profile_selection_indices(ss, sel)?,
ProfileSel::Points { points } => {
let Some(indices) = points.iter().map(|point| profile_at_point(&ss.profiles, *point)).collect::<Option<Vec<_>>>() else { continue };
indices
}
ProfileSel::Curves { loops } => {
// The command's ordinary curve lists are sets, including repeated
// caller IDs. Saved graph-span multisets retain their legacy meaning.
// Reserved offset roles never use this ordinary normalization.
let mut normalized = loops.clone();
let reserved = ss
.sketch
.offsets
.iter()
.filter_map(|offset| offset.topology.as_ref())
.flat_map(|topology| topology.roles.iter().map(|role| role.output.as_str()))
.collect::<std::collections::BTreeSet<_>>();
for curves in &mut normalized {
let dynamic = curves.iter().any(|curve| reserved.contains(curve.as_str()));
if !dynamic {
let mut seen = std::collections::BTreeSet::new();
curves.retain(|curve| seen.insert(curve.clone()));
}
}
let selection = ProfileSel::Curves { loops: normalized };
solidcraft_doc::profile_selection_indices(ss, &selection)?;
*sel = selection;
continue;
}
ProfileSel::OffsetBoundaries { .. } | ProfileSel::WholeBoundaries { .. } => {
// Explicit identities retain the caller's semantics. Validate through the
// same resolver as recomputation, including reserved-role precedence.
solidcraft_doc::profile_selection_indices(ss, sel)?;
continue;
}
ProfileSel::All => continue,
};
let captured = solidcraft_doc::capture_profile_selection(ss, &indices)?;
*sel = match captured {
ProfileSel::OffsetBoundaries { associations }
if associations.iter().any(|id| {
s.doc
.sketch(sketch)
.is_ok_and(|raw| !raw.offsets.iter().filter_map(|offset| offset.topology.as_ref()).any(|topology| topology.id == *id))
}) =>
{
// Legacy upgrade descriptors belong to the solved copy. Persist the
// complete original role IDs instead of an unsaved association ID.
let loops = associations
.iter()
.map(|id| {
ss.sketch
.offsets
.iter()
.filter_map(|offset| offset.topology.as_ref())
.find(|topology| topology.id == *id)
.map(|topology| topology.roles.iter().map(|role| role.output.clone()).collect())
.ok_or_else(|| EngineError::Other(format!("offset association `{id}` changed during profile capture")))
})
.collect::<Result<Vec<_>>>()?;
ProfileSel::Curves { loops }
}
captured => captured,
};
}
Ok(())
}
pub(super) fn profiles(p: &Value, cmd: &str) -> Result<ProfileSel> {
let Some(v) = p.get("profiles") else { return Ok(ProfileSel::All) };
match v {
Value::Object(fields) => {
if fields.get("type").and_then(Value::as_str) == Some("whole_boundaries") {
return solidcraft_doc::whole_profile_selection_from_value(v).map_err(|error| bad(cmd, error.to_string()));
}
let associations = fields
.get("offset_boundaries")
.or_else(|| (fields.get("type").and_then(Value::as_str) == Some("offset_boundaries")).then(|| fields.get("associations")).flatten())
.and_then(Value::as_array)
.ok_or_else(|| bad(cmd, "profile object must provide `offset_boundaries: [association ids]`"))?;
if associations.is_empty() || associations.len() > 128 {
return Err(bad(cmd, "offset profile selection needs 1 to 128 association ids"));
}
let associations = associations
.iter()
.map(|id| {
id.as_str()
.filter(|id| !id.is_empty() && id.len() <= 512)
.map(str::to_string)
.ok_or_else(|| bad(cmd, "offset association ids must be nonempty strings of at most 512 bytes"))
})
.collect::<Result<Vec<_>>>()?;
if associations.iter().collect::<std::collections::BTreeSet<_>>().len() != associations.len() {
return Err(bad(cmd, "offset association ids must be unique"));
}
Ok(ProfileSel::OffsetBoundaries { associations })
}
Value::String(s) if s.eq_ignore_ascii_case("all") => Ok(ProfileSel::All),
Value::Number(n) => Ok(ProfileSel::Indices { indices: vec![n.as_u64().ok_or_else(|| bad(cmd, "profile index"))? as usize] }),
Value::Array(a) if a.is_empty() => Err(bad(cmd, "`profiles` is empty")),
Value::Array(a) if a.len() > 10_000 => Err(bad(cmd, "too many profiles")),
Value::Array(a) => {
if a.iter().all(Value::is_u64) {
return Ok(ProfileSel::Indices { indices: a.iter().filter_map(Value::as_u64).map(|x| x as usize).collect() });
}
if a.iter().all(|x| x.get("point").is_some()) {
let pts =
a.iter().map(|x| x.get("point").and_then(vec2).ok_or_else(|| bad(cmd, "profile point must be [x, y]"))).collect::<Result<_>>()?;
return Ok(ProfileSel::Points { points: pts });
}
// [[ids]], [{curves: [...]}] or recipe-style [{loops: [{outer: true, curves: [...]}]}].
let mut loops = Vec::new();
for x in a {
let ids: Vec<String> = match x {
Value::Array(ids) => ids.iter().filter_map(|i| i.as_str().map(str::to_string)).collect(),
Value::Object(o) => {
if let Some(c) = o.get("curves") {
string_list(&json!({"c": c}), "c")
} else if let Some(ls) = o.get("loops").and_then(Value::as_array) {
let outer = ls.iter().find(|l| l.get("outer").and_then(Value::as_bool).unwrap_or(true)).or(ls.first());
outer.map(|l| string_list(l, "curves")).unwrap_or_default()
} else {
Vec::new()
}
}
_ => Vec::new(),
};
if ids.is_empty() {
return Err(bad(cmd, "each profile must be an index, a list of curve ids, {curves}, {loops} or {point}"));
}
loops.push(ids);
}
Ok(ProfileSel::Curves { loops })
}
_ => Err(bad(cmd, "`profiles` must be \"all\" or a list")),
}
}
pub(super) fn check_expr(s: &Session, e: &str, kind: Kind, cmd: &str, what: &str) -> Result<()> {
s.doc.eval(e, kind).map(|_| ()).map_err(|err| bad(cmd, format!("{what}: {err}")))
}
/// Add a feature, evaluate, and report its result (an evaluation error fails the command).
pub(super) fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Result<Value> {
add_feature_in(s, p, kind, true)
}
/// `add_feature`; `world` is false when `kind` is already in the active component's frame.
fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) -> Result<Value> {
let name = str_(p, "name");
// The bodies before the feature, to say which ones it changed or removed.
let before = s.model.state();
// Commands speak world coordinates; the feature is authored in the active component.
let mut kind = kind;
if world {
super::component::to_active_frame(s, &mut kind);
}
// Bare lengths typed in an inch (or cm…) design mean that unit.
if s.doc.units.trim() != "mm" {
let doc = s.doc.clone();
for (_, e, k) in kind.inputs_mut() {
*e = doc.with_design_unit(e, k).into();
}
}
// Profiles picked by index are kept by the curves around them: a recompute can renumber a
// sketch's profiles (a face sketch's projected outline rebuilt), never the curves.
stable_profiles(s, &mut kind)?;
// Blends remember where their edges sat, to find them again after upstream edits.
let (refs, names) = match &kind {
FeatureKind::Fillet { edges, body, .. } | FeatureKind::Chamfer { edges, body, .. } => {
let st = s.model.state();
(
solidcraft_doc::edge_refs(&st, body, &solidcraft_doc::plain(edges)),
solidcraft_doc::edge_names_for(&st, body, &solidcraft_doc::plain(edges)),
)
}
// Sheet metal edges are named by the sheet model.
FeatureKind::SheetFlange { edges, .. } => {
let points: Vec<_> = edges.iter().map(|e| e.point).collect();
(Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), &solidcraft_doc::plain(&points)))
}
FeatureKind::SheetHem { edges, .. } => (Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), &solidcraft_doc::plain(edges))),
// A conversion's bend edges are edges of the body it converts.
FeatureKind::SheetConvert { body, bends, .. } if !bends.is_empty() => {
(Vec::new(), solidcraft_doc::sheet::bend_edge_names(&s.model.state(), body, &solidcraft_doc::plain(bends)))
}
_ => (Vec::new(), Vec::new()),
};
let face_names = {
let pts = kind.face_points();
if let FeatureKind::Emboss { face: Some(face), .. } = &kind {
let state = s.model.state();
let body = state.body(&face.body).ok_or_else(|| EngineError::Other("selected cylinder body no longer exists".into()))?;
let frame = face.body_frame(&s.doc, body).map_err(|e| EngineError::Other(e.to_string()))?;
let inverse = solidcraft_doc::mat_inverse(&frame).ok_or_else(|| EngineError::Other("invalid cylinder placement".into()))?;
let at = solidcraft_doc::apply_point(&solidcraft_doc::mat_mul(&inverse, &s.active_component_frame()), face.at.0);
let mut scoped = solidcraft_doc::ModelState::default();
scoped.bodies.push(body.clone());
let names = solidcraft_doc::naming::face_names_at(&scoped, &[at]);
if names.len() != 1 || names.first().is_none_or(|name| name.is_empty()) {
return Err(EngineError::Other("selected cylinder face has no persistent identity".into()));
}
names
} else if pts.is_empty() {
Vec::new()
} else {
solidcraft_doc::face_point_names(&s.doc, &s.model.state(), &kind, s.active_component)
}
};
// Primitives have no `targets` of their own: their `targets` are the bodies taking part (#259).
let primitive = matches!(kind, FeatureKind::Box { .. } | FeatureKind::Cylinder { .. } | FeatureKind::Sphere { .. } | FeatureKind::Torus { .. });
let (participants, key) = match string_list(p, "participants") {
v if v.is_empty() && primitive => (string_list(p, "targets"), "targets"),
v => (v, "participants"),
};
if participants.len() > 10_000 || participants.iter().any(|b| b != "*" && s.model.state().body(b).is_none()) {
return Err(EngineError::Other(format!("`{key}` must list existing bodies")));
}
// A cut or intersect made where the active component has no body of its own (a sketch in
// the assembly's root, cutting through the parts, #1234) would have nothing to work on: it
// takes part in every body instead, which it changes only where it meets them.
let participants = if participants.is_empty()
&& !primitive
&& matches!(str_(p, "operation").and_then(Operation::parse), Some(Operation::Cut | Operation::Intersect))
&& {
let st = s.model.state();
!st.bodies.is_empty() && !st.bodies.iter().any(|b| s.doc.model_body_component(b) == s.active_component)
} {
vec!["*".to_string()]
} else {
participants
};
if matches!(kind, FeatureKind::ConstructionPlane { .. })
&& let Some(n) = name
{
check_plane_name(n, "construct.plane")?;
}
let id = s.doc_mut().add_feature(kind, name)?;
if let Some(f) = s.doc_mut().feature_mut(id) {
f.participants = participants;
f.edge_refs = refs;
f.edge_names = names;
f.face_names = face_names;
}
s.doc_mut().name_feature_inputs(id);
let comp = s.active_component;
if comp != 0
&& let Some(f) = s.doc_mut().feature_mut(id)
{
f.component = comp;
}
let mut names = string_list(p, "body_names");
if let Some(b) = str_(p, "body_name") {
names.insert(0, b.to_string());
}
if !names.is_empty()
&& let Some(f) = s.doc_mut().feature_mut(id)
{
f.body_names = names.clone();
}
s.active_sketch = None;
s.refresh();
// A feature that makes more bodies than it was given names (a multi-profile extrude with one
// `body_name`) has the rest numbered after the first by the evaluator: `Ribs`, `Ribs (2)`,
// `Ribs (3)`… (#266, #373). They are stored on the feature, so recomputes and saved designs
// keep them even when the profiles are later picked in another order.
if names.first().is_some_and(|n| !n.trim().is_empty()) {
let made: Vec<String> = s.model.state().bodies.iter().filter(|b| b.feature == id).map(|b| b.name.clone()).collect();
if made.len() > names.len()
&& let Some(f) = s.doc_mut().feature_mut(id)
{
f.body_names = made;
}
}
let r = s.model.result(id).cloned();
if let Some(e) = r.as_ref().and_then(|r| r.error.clone()) {
return Err(EngineError::Other(e));
}
let st = s.model.state();
let bodies: Vec<&str> = st.bodies.iter().map(|b| b.name.as_str()).collect();
// The bodies this feature made, apart from the ones it changed or that were there (#374).
let created: Vec<&str> = st.bodies.iter().filter(|b| b.feature == id).map(|b| b.name.as_str()).collect();
// Bodies that were there before and now have other geometry, and bodies that are gone.
let old: std::collections::HashMap<&str, &solidcraft_doc::kernel::Body> = before.bodies.iter().map(|b| (b.name.as_str(), &b.body)).collect();
let now: std::collections::HashSet<&str> = st.bodies.iter().map(|b| b.name.as_str()).collect();
let changed: Vec<&str> =
st.bodies.iter().filter(|b| b.feature != id && old.get(b.name.as_str()).is_some_and(|o| !o.same(&b.body))).map(|b| b.name.as_str()).collect();
let removed: Vec<&str> = before.bodies.iter().map(|b| b.name.as_str()).filter(|n| !now.contains(n)).collect();
let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
// A warning (a Boolean that needed a retry, a reference found again elsewhere) is part of
// the reply: a script or an agent sees it without asking the timeline (#616).
let warning = r.as_ref().and_then(|r| r.warning.clone());
Ok(json!({"feature": id, "name": fname, "bodies": bodies, "created_bodies": created, "changed_bodies": changed,
"removed_bodies": removed, "warning": warning, "ms": r.map(|r| r.ms)}))
}
fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.extrude";
// A planar body face extrudes like a profile: its boundary is copied into a sketch on it.
let (sketch, face_profile, face_body) = match p.get("face").and_then(vec3) {
Some(fp) => {
let (id, inside, body) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
(id, Some(ProfileSel::Points { points: vec![inside] }), Some(body))
}
None => (feature_sketch(s, p, cmd)?, None, None),
};
let extent = extent_param(s, p, cmd)?;
let thin = match bool_(p, "thin") {
Some(true) => {
if face_profile.is_some() || p.get("profiles").is_some() {
return Err(bad(cmd, "a thin extrude follows `curves`; it takes no `profiles` or `face`"));
}
Some(thin_param(s, p, sketch, cmd)?)
}
_ if p.get("thin").is_some_and(|v| !v.is_boolean()) => return Err(bad(cmd, "`thin` must be true or false")),
_ => None,
};
let operation = match str_(p, "operation") {
Some(o) if o.eq_ignore_ascii_case("auto") => Operation::parse(auto_operation(s, p).unwrap_or("new")).unwrap_or(Operation::NewBody),
_ => operation(p, cmd)?,
};
let mut targets = string_list(p, "targets");
if operation == Operation::Join && targets.is_empty() && string_list(p, "participants").is_empty() {
// A face's outward extension joins that body, and so does a profile sketched on a body's
// face. Assembly neighbours can touch only an edge of the tool: trying to join them
// first fails, or absorbs unrelated parts (#226). A profile drawn past the face can lie
// on a neighbour's face too: a rib on a bracket's side welded in the motor the bracket
// holds, a join that ran for minutes and failed (#1349).
// Store the resolved target so recomputes and reopening keep the same scope.
targets.extend(face_body.or_else(|| sketch_face_body(s, sketch)));
}
let kind = FeatureKind::Extrude {
sketch: SketchId(sketch),
profiles: match face_profile {
Some(f) => f,
None if thin.is_some() => ProfileSel::All,
None => profiles(p, cmd)?,
},
extent,
operation,
targets: targets.into_iter().map(BodyName).collect(),
thin,
};
add_feature(s, p, kind)
}
/// The body whose face `sketch` is attached to (on the face itself, not offset from it), when
/// it is a body of the active component.
fn sketch_face_body(s: &Session, sketch: u64) -> Option<String> {
let FeatureKind::Sketch { plane: solidcraft_doc::PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &s.doc.feature(sketch)?.kind else {
return None;
};
let st = s.model.state();
let body = st.body(&anchor.target.body)?;
(s.doc.model_body_component(body) == s.active_component).then(|| body.name.clone())
}
/// A thin extrude's wall from its parameters: the sketch's curves it follows (every drawn
/// curve when none are given), its thickness and which side of them it lies on.
fn thin_param(s: &Session, p: &Value, sketch: u64, cmd: &str) -> Result<ThinWall> {
let st = s.model.state();
let ss = st.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch is not evaluated"))?;
let mut curves = string_list(p, "curves");
if curves.is_empty() {
curves =
ss.sketch.curves.iter().filter(|c| !c.construction && !c.centerline && !c.infinite && c.link.is_none()).map(|c| c.id.clone()).collect();
}
if curves.is_empty() || curves.len() > 1000 {
return Err(bad(cmd, "a thin extrude needs 1…1000 sketch curves (`curves`)"));
}
if let Some(c) = curves.iter().find(|c| ss.sketch.curve_index(c).is_none()) {
return Err(bad(cmd, format!("no curve `{c}` in the sketch")));
}
let thickness = req_expr(cmd, p, "thickness")?;
check_expr(s, &thickness, Kind::Length, cmd, "thickness")?;
let side = match str_(p, "side") {
None => ThinSide::Center,
Some(o) => ThinSide::parse(o).ok_or_else(|| bad(cmd, format!("unknown side `{o}` (center, inside, outside or two_sides)")))?,
};
let thickness2 = expr(p, "thickness2");
match (&thickness2, side) {
(Some(t), ThinSide::TwoSides) => check_expr(s, t, Kind::Length, cmd, "thickness2")?,
(None, ThinSide::TwoSides) => return Err(bad(cmd, "`side: two_sides` needs `thickness2` (the thickness outside)")),
(Some(_), _) => return Err(bad(cmd, "`thickness2` goes with `side: two_sides`")),
(None, _) => {}
}
let thin = ThinWall { curves, thickness, side, thickness2 };
// Refuse a wall that can't be built now, with the reason, rather than a failed feature.
thin_regions(s, ss, &thin).map_err(|e| bad(cmd, e))?;
Ok(thin)
}
/// The regions of a thin wall in its (solved) sketch, with the current parameter values.
fn thin_regions(s: &Session, ss: &solidcraft_doc::SolvedSketch, thin: &ThinWall) -> std::result::Result<Vec<solidcraft_geom::Region2>, String> {
let length = |e: &str| s.doc.eval(&s.doc.with_design_unit(e, Kind::Length), Kind::Length).map_err(|e| e.to_string());
let t = length(&thin.thickness)?;
let (inside, outside) = match thin.side {
ThinSide::Center => (t / 2.0, t / 2.0),
ThinSide::Inside => (t, 0.0),
ThinSide::Outside => (0.0, t),
ThinSide::TwoSides => (t, length(thin.thickness2.as_deref().unwrap_or("0"))?),
};
let curves: Vec<Vec<solidcraft_geom::Seg2>> = thin
.curves
.iter()
.filter_map(|id| ss.sketch.curve_index(id))
.map(|ci| ss.sketch.exact_segs(ci).unwrap_or_else(|| ss.sketch.segs(ci)))
.collect();
solidcraft_sketch::thin_wall_regions(&curves, inside, outside).map_err(|e| format!("thin wall: {e}"))
}
/// An extrude's extent from its parameters: distance (or through all, or to an object), the
/// side(s), a second distance, a start offset, a taper, and from an object.
pub(super) fn extent_param(s: &Session, p: &Value, cmd: &str) -> Result<Extent> {
let through_all = bool_(p, "through_all").unwrap_or(false) || str_(p, "extent").is_some_and(|e| e.eq_ignore_ascii_case("through_all"));
let to_object = p.get("to").is_some();
if to_object && p.get("to").and_then(vec3).is_none() {
return Err(bad(cmd, "`to` must be a point [x, y, z] on the face, vertex or point to extrude to"));
}
if p.get("from").is_some() && p.get("from").and_then(vec3).is_none() {
return Err(bad(cmd, "`from` must be a point [x, y, z] on the face or point to start from"));
}
let distance = if through_all || to_object { expr(p, "distance").unwrap_or_else(|| "0".into()) } else { req_expr(cmd, p, "distance")? };
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
let taper = expr(p, "taper");
if let Some(t) = &taper {
check_expr(s, t, Kind::Angle, cmd, "taper")?;
}
let direction = match str_(p, "direction").map(str::to_ascii_lowercase).as_deref() {
None | Some("positive") | Some("one_side") => Direction::Positive,
Some("negative") | Some("reverse") | Some("flip") => Direction::Negative,
Some("symmetric") => Direction::Symmetric,
Some(o) => return Err(bad(cmd, format!("unknown direction `{o}`"))),
};
let distance2 = expr(p, "distance2");
if let Some(d) = &distance2 {
check_expr(s, d, Kind::Length, cmd, "distance2")?;
}
let start_offset = expr(p, "start_offset");
if let Some(d) = &start_offset {
check_expr(s, d, Kind::Length, cmd, "start_offset")?;
}
let convention = match p.get("convention") {
// Surface Extrude shares Extent but retains its existing per-side distance semantics.
None if cmd == "surface.extrude" => solidcraft_doc::ExtrudeConvention::LegacyPerSide,
None => solidcraft_doc::ExtrudeConvention::TotalDistance,
Some(value) => serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "`convention` must be total_distance or legacy_per_side"))?,
};
Ok(Extent {
distance,
convention,
direction,
distance2,
start_offset,
through_all,
taper,
to: p.get("to").and_then(vec3).map(FacePick),
to_offset: expr(p, "to_offset"),
from: p.get("from").and_then(vec3).map(FacePick),
})
}
fn revolve(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.revolve";
// A planar body face revolves like a profile (its edges projected into a sketch on it).
let (sketch, face_profile) = match p.get("face").and_then(vec3) {
Some(fp) => {
let (id, inside, _) = super::face::sketch_of_face(s, fp, cmd, str_(p, "face_body"))?;
s.active_sketch = None;
(id, Some(ProfileSel::Points { points: vec![inside] }))
}
None => (feature_sketch(s, p, cmd)?, None),
};
let axis = axis_param(p, cmd)?;
let angle = expr(p, "angle").unwrap_or_else(|| "360 deg".into());
check_expr(s, &angle, Kind::Angle, cmd, "angle")?;
let profiles = match face_profile {
Some(f) => f,
None => profiles(p, cmd)?,
};
let angle2 = expr(p, "angle2");
if let Some(a) = &angle2 {
check_expr(s, a, Kind::Angle, cmd, "angle2")?;
}
let to = match p.get("to") {
Some(v) => Some(vec3(v).ok_or_else(|| bad(cmd, "`to` must be a point [x, y, z] to turn to"))?),
None => None,
};
let symmetric =
matches!(str_(p, "direction").map(str::to_ascii_lowercase).as_deref(), Some("symmetric")) || bool_(p, "symmetric").unwrap_or(false);
let convention = match p.get("convention") {
None => solidcraft_doc::RevolveConvention::SketchNormal,
Some(value) => serde_json::from_value(value.clone()).map_err(|_| bad(cmd, "`convention` must be sketch_normal or legacy_axis_start"))?,
};
let kind = FeatureKind::Revolve {
sketch: SketchId(sketch),
profiles,
axis,
angle,
operation: operation(p, cmd)?,
targets: string_list(p, "targets").into_iter().map(BodyName).collect(),
angle2,
symmetric,
convention,
to: to.map(FacePick),
};
add_feature(s, p, kind)
}
/// A revolve axis from its parameter: a sketch line id, the sketch's x or y axis, a world axis,
/// or {origin, dir}.
pub(super) fn axis_param(p: &Value, cmd: &str) -> Result<AxisRef> {
if let Some(o) = p.get("axis").filter(|v| v.is_object()) {
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
let dir = o.get("dir").and_then(vec3).ok_or_else(|| bad(cmd, "axis needs `dir`"))?;
return Ok(AxisRef::Line { origin: Position(origin), dir: Dir(dir) });
}
match str_(p, "axis") {
Some(a @ ("X" | "Y" | "Z")) => Ok(AxisRef::World { axis: a.into() }),
Some(a @ ("x" | "y")) => Ok(AxisRef::SketchAxis { axis: a.into() }),
Some(id) => Ok(AxisRef::SketchLine { curve: id.into() }),
None => Err(bad(cmd, "`axis` must be a sketch line id, x, y (sketch axes), X, Y, Z or {origin, dir}")),
}
}
fn radius_expr(s: &Session, p: &Value, cmd: &str) -> Result<Expr> {
match (expr(p, "radius"), expr(p, "diameter")) {
(Some(r), _) => Ok(r),
(None, Some(d)) => {
let [d] = length_operands(s, [(&d, &typed_length_unit(s))]);
Ok(format!("({d}) / 2").into())
}
_ => Err(bad(cmd, "needs `radius` or `diameter`")),
}
}
/// The unit a bare length typed into a command means: the design's units. (A point's bare
/// coordinates, and a bare number already stored in a feature, are millimetres.)
pub(super) fn typed_length_unit(s: &Session) -> String {
solidcraft_doc::expr::length_unit_or_mm(&s.doc.units).to_string()
}
/// The lengths a command works one expression out from (a radius from a diameter, a pitch from
/// a height), each with the unit a bare number means where it came from. Bare numbers get that
/// unit written in, so the expression keeps the meaning they had: inside a larger expression a
/// bare number is read in the design's units of the moment, and changing them would resize the
/// part (#716). Next to a unit-less expression that is not a bare number (`2 * 5`, a unit-less
/// parameter), which is read in the design's units itself, they stay bare and are read with it,
/// as before.
pub(super) fn length_operands<const N: usize>(s: &Session, operands: [(&str, &str); N]) -> [String; N] {
use solidcraft_doc::expr::is_bare_number;
let follows = operands.iter().any(|(e, _)| !is_bare_number(e) && s.doc.eval(e, Kind::Unitless).is_ok());
operands.map(|(e, unit)| if !follows && is_bare_number(e) { format!("{} {unit}", e.trim()) } else { e.to_string() })
}
/// A placement point: `[x, y, z]` (or `{x, y, z}`) of numbers (mm) or length expressions;
/// the origin when it is not given. Anything else is refused (never read as the origin).
pub(super) fn point_param(s: &Session, p: &Value, key: &str, cmd: &str) -> Result<Option<PointExpr>> {
let Some(v) = p.get(key) else { return Ok(None) };
let want = || bad(cmd, format!("`{key}` must be a point [x, y, z] of numbers or length expressions"));
let coords: Vec<&Value> = match v {
Value::Array(a) if a.len() == 3 => a.iter().collect(),
Value::Object(o) => ["x", "y", "z"].iter().map(|k| o.get(*k)).collect::<Option<Vec<_>>>().ok_or_else(want)?,
_ => return Err(want()),
};
let mut out = PointExpr::default();
for (slot, (c, axis)) in out.iter_mut().zip(coords.into_iter().zip(["x", "y", "z"])) {
let e = expr(&json!({ "v": c }), "v").ok_or_else(want)?;
let x = s.doc.eval(&e, Kind::Length).map_err(|err| bad(cmd, format!("{key} {axis}: {err}")))?;
if !(x.is_finite() && x.abs() < 1e9) {
return Err(bad(cmd, format!("{key} {axis} is out of range")));
}
*slot = e;
}
Ok(Some(out))
}
/// An axis direction: X|Y|Z or a non-zero `[x, y, z]`; `Z` when it is not given.
pub(super) fn axis_dir_param(p: &Value, cmd: &str) -> Result<Vec3> {
match p.get("axis") {
None => Ok(Vec3::Z),
Some(Value::String(a)) => match a.trim().to_ascii_uppercase().as_str() {
"X" => Ok(Vec3::X),
"Y" => Ok(Vec3::Y),
"Z" => Ok(Vec3::Z),
_ => Err(bad(cmd, "`axis` must be X, Y, Z or [x, y, z]")),
},
Some(v) => vec3(v).and_then(|a| a.normalized()).ok_or_else(|| bad(cmd, "`axis` must be X, Y, Z or a non-zero [x, y, z]")),
}
}
fn prim_box(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.box";
let (l, w, h) = (req_expr(cmd, p, "length")?, req_expr(cmd, p, "width")?, req_expr(cmd, p, "height")?);
for (e, n) in [(&l, "length"), (&w, "width"), (&h, "height")] {
check_expr(s, e, Kind::Length, cmd, n)?;
}
let corner = match (point_param(s, p, "corner", cmd)?, point_param(s, p, "center", cmd)?) {
(Some(c), _) => c,
// The corner half a size back from the centre (numbers stay numbers, in mm). The centre's
// bare numbers are millimetres, the sizes' the design's units.
(None, Some([cx, cy, cz])) => {
let unit = typed_length_unit(s);
let mm_per_unit = solidcraft_doc::expr::unit_info(&unit).map_or(1.0, |(_, k)| k);
let back = |c: Expr, size: &Expr| -> Expr {
match (c.parse::<f64>(), size.parse::<f64>()) {
(Ok(a), Ok(b)) => format!("{}", a - b * mm_per_unit / 2.0).into(),
_ => {
let [c, size] = length_operands(s, [(&c, "mm"), (size, &unit)]);
format!("({c}) - ({size}) / 2").into()
}
}
};
[back(cx, &l), back(cy, &w), back(cz, &h)]
}
_ => point_expr(Vec3::ZERO),
};
add_feature(s, p, FeatureKind::Box { corner, length: l, width: w, height: h, operation: operation(p, cmd)? })
}
fn prim_cylinder(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.cylinder";
let radius = radius_expr(s, p, cmd)?;
let height = req_expr(cmd, p, "height")?;
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
check_expr(s, &height, Kind::Length, cmd, "height")?;
let base = point_param(s, p, "base", cmd)?.unwrap_or_else(|| point_expr(Vec3::ZERO));
let axis = axis_dir_param(p, cmd)?;
add_feature(s, p, FeatureKind::Cylinder { base, axis: Dir(axis), radius, height, operation: operation(p, cmd)? })
}
fn prim_sphere(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.sphere";
let radius = radius_expr(s, p, cmd)?;
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
let center = point_param(s, p, "center", cmd)?.unwrap_or_else(|| point_expr(Vec3::ZERO));
add_feature(s, p, FeatureKind::Sphere { center, radius, operation: operation(p, cmd)? })
}
fn prim_torus(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.torus";
let (major, minor) = (req_expr(cmd, p, "major")?, req_expr(cmd, p, "minor")?);
check_expr(s, &major, Kind::Length, cmd, "major")?;
check_expr(s, &minor, Kind::Length, cmd, "minor")?;
let center = point_param(s, p, "center", cmd)?.unwrap_or_else(|| point_expr(Vec3::ZERO));
add_feature(s, p, FeatureKind::Torus { center, major, minor, operation: operation(p, cmd)? })
}
/// Edge references → points on the edges.
fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Vec3>> {
let a = p.get("edges").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`edges` must be a list"))?;
if a.is_empty() || a.len() > 1000 {
return Err(bad(cmd, "select 1…1000 edges"));
}
let st = s.model.state();
// A picked point must lie on an edge (within the distance the kernel re-finds an edge from)
// of the body the feature names, or of any body: a pick far from every edge would round
// whichever edge is nearest (#783). Each body's edges are worked out once.
let named = str_(p, "body").filter(|n| st.body(n).is_some());
let mut candidates: Option<Vec<(f64, Vec<solidcraft_kernel::EdgeInfo>)>> = None;
let mut out = Vec::new();
for e in a {
if let Some(pt) = vec3(e).or_else(|| e.get("point").and_then(vec3)) {
let bodies = candidates.get_or_insert_with(|| {
// Mesh bodies have no edges to pick: rounding one is refused later, saying why.
st.bodies
.iter()
.filter(|b| named.is_none_or(|n| b.name == n) && !b.body.is_mesh())
.filter_map(|b| {
let size = b.body.size();
b.body.edges((size * 2e-3).max(1e-3)).ok().map(|edges| (size, edges))
})
.collect()
});
let mut nearest = f64::INFINITY;
let on_edge = bodies.iter().any(|(size, edges)| {
let d = edges
.iter()
.flat_map(|e| e.points.windows(2))
.filter_map(|w| Some(pt.dist_to_segment(*w.first()?, *w.get(1)?)))
.fold(f64::INFINITY, f64::min);
nearest = nearest.min(d);
d <= size * 0.05 + 1e-3
});
if !on_edge && !bodies.is_empty() {
let far = if nearest.is_finite() { format!("the nearest edge is {nearest:.3} mm away") } else { "there is no edge".to_string() };
return Err(bad(cmd, format!("no edge at [{}, {}, {}]: {far}", pt.x, pt.y, pt.z)));
}
out.push(pt);
continue;
}
let body = e.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "an edge is a point [x,y,z], {point} or {body, index}"))?;
let idx = e.get("index").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "edge `index` must be a number"))? as usize;
let b = st.body(body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?;
let tol = (b.body.size() * 2e-3).max(1e-3);
let edges = b.body.edges(tol)?;
out.push(edges.get(idx).ok_or_else(|| bad(cmd, format!("{body} has no edge {idx}")))?.mid);
}
Ok(out)
}
/// Press Pull: a face moves along its normal (an extrude that joins outward or cuts inward); an
/// edge is rounded (a fillet).
fn press_pull(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.press_pull";
let distance = req_expr(cmd, p, "distance")?;
if p.get("edges").is_some() {
return fillet(s, &json!({"edges": p.get("edges"), "radius": distance}));
}
let face = p.get("face").ok_or_else(|| bad(cmd, "needs `face` or `edges`"))?;
let d = s.doc.eval(&distance, Kind::Length).map_err(|e| bad(cmd, format!("distance: {e}")))?;
if d.abs() < 1e-9 {
return Err(bad(cmd, "the distance must not be zero"));
}
// A fillet's or chamfer's face: its size changes by the distance (the feature is edited).
if let Some(fp) = vec3(face)
&& super::face::face_is_planar(s, fp) == Some(false)
{
let name = solidcraft_doc::naming::face_names_at(&s.model.state(), &[fp]).into_iter().next().unwrap_or_default();
let fid = name.strip_prefix('F').and_then(|r| r.split(':').next()).and_then(|x| x.parse::<u64>().ok());
if name.contains(":blend") {
let f = fid.and_then(|id| s.doc.feature(id)).cloned().ok_or_else(|| bad(cmd, "the blend's feature is gone"))?;
let (key, old) = match &f.kind {
FeatureKind::Fillet { radius, .. } => ("radius", radius.clone()),
FeatureKind::Chamfer { distance, .. } => ("distance", distance.clone()),
_ => return Err(bad(cmd, "that face is not a fillet's or a chamfer's")),
};
// A bare number stored in the feature is millimetres; the distance's, the design's units.
let [old, more] = length_operands(s, [(&old, "mm"), (&distance, &typed_length_unit(s))]);
let new = format!("({old}) + ({more})");
let r = super::find_command("timeline.edit").ok_or_else(|| bad(cmd, "timeline.edit"))?;
let out = (r.run)(s, &json!({"feature": f.id, "set": {key: new}}))?;
return Ok(json!({"feature": f.id, "edited": f.name, key: new, "recomputed": out.get("recomputed"), "restored": out.get("restored")}));
}
// Another curved face moves along its normal.
let r = super::find_command("solid.offset_face").ok_or_else(|| bad(cmd, "solid.offset_face"))?;
return (r.run)(s, &json!({"faces": [face], "distance": distance}));
}
// Pushed in: the extrude cuts the other way by as much (a bare number stays one, so it keeps
// being read as a bare number typed into the extrude).
let flipped = match distance.trim().parse::<f64>() {
Ok(v) if solidcraft_doc::expr::is_bare_number(&distance) => Expr::new(format!("{}", -v)),
_ => Expr::new(format!("-({distance})")),
};
let (dist, dir, op) = if d > 0.0 { (distance, "positive", "join") } else { (flipped, "negative", "cut") };
extrude(s, &json!({"face": face, "distance": dist, "direction": dir, "operation": op}))
}
fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.fillet";
let edges = edge_points(s, p, cmd)?;
let radius = req_expr(cmd, p, "radius")?;
check_expr(s, &radius, Kind::Length, cmd, "radius")?;
let style = match str_(p, "type").map(str::to_ascii_lowercase).as_deref() {
None | Some("constant") | Some("rolling_ball") => solidcraft_doc::FilletStyle::Constant,
Some("chord") | Some("chord_length") => solidcraft_doc::FilletStyle::Chord,
Some("curvature") | Some("g2") => solidcraft_doc::FilletStyle::Curvature,
Some("variable") => {
let radius2 = req_expr(cmd, p, "radius2")?;
check_expr(s, &radius2, Kind::Length, cmd, "radius2")?;
let start =
p.get("start").and_then(vec3).ok_or_else(|| bad(cmd, "a variable fillet needs `start`: [x,y,z] near the end that takes `radius`"))?;
let mut mid = Vec::new();
for m in p.get("mid").and_then(Value::as_array).into_iter().flatten().take(100) {
let (Some(t), Some(r)) = (m.get(0).and_then(Value::as_f64), m.get(1)) else {
return Err(bad(cmd, "`mid` entries are [position, radius]"));
};
if !(t.is_finite() && t > 0.0 && t < 1.0) {
return Err(bad(cmd, "`mid` positions lie between 0 and 1 (from the start end)"));
}
let r = match r {
Value::String(x) => x.clone(),
Value::Number(n) => n.to_string(),
_ => return Err(bad(cmd, "a `mid` radius is a number or an expression")),
};
check_expr(s, &r, Kind::Length, cmd, "mid radius")?;
mid.push((t, r.into()));
}
let smooth = p.get("smooth").and_then(Value::as_bool).unwrap_or(false);
solidcraft_doc::FilletStyle::Variable { radius2, start: Position(start), mid, smooth }
}
Some("spline") => {
let start = p
.get("start")
.and_then(vec3)
.ok_or_else(|| bad(cmd, "a spline fillet needs `start`: [x,y,z] near the end its positions start from"))?;
let knots: Vec<f64> = p.get("knots").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_f64).collect();
let mut radii = Vec::new();
for r in p.get("radii").and_then(Value::as_array).into_iter().flatten().take(100) {
let r = match r {
Value::String(x) => x.clone(),
Value::Number(n) => n.to_string(),
_ => return Err(bad(cmd, "a control radius is a number or an expression")),
};
check_expr(s, &r, Kind::Length, cmd, "control radius")?;
radii.push(Expr::from(r));
}
// All control radii (`radius` and `radii`) plus the degree (1…3) plus one.
if radii.is_empty() || !(radii.len() + 3..=radii.len() + 5).contains(&knots.len()) {
return Err(bad(
cmd,
"a spline fillet needs `radii` (the control radii after `radius`) and `knots` (as many as all control radii, the degree 1…3 and one more)",
));
}
solidcraft_doc::FilletStyle::Spline { start: Position(start), knots, radii }
}
Some(o) => return Err(bad(cmd, format!("unknown fillet type `{o}` (constant, chord, variable, spline or curvature)"))),
};
let fit = match str_(p, "fit").map(str::to_ascii_lowercase).as_deref() {
None | Some("exact") => solidcraft_doc::FilletFit::Exact,
Some("reduce") => solidcraft_doc::FilletFit::Reduce,
Some("taper") => solidcraft_doc::FilletFit::Taper,
Some(o) => return Err(bad(cmd, format!("unknown fit `{o}` (exact, reduce or taper)"))),
};
if !fit.is_exact() && !style.is_constant() {
return Err(bad(cmd, "`fit: reduce` and `fit: taper` take a constant radius"));
}
// What fills the corners where three blended edges meet.
let corner = match str_(p, "corner").map(str::to_ascii_lowercase).as_deref() {
None | Some("rolling") => solidcraft_doc::FilletCorner::Rolling,
Some("g2") => solidcraft_doc::FilletCorner::G2,
Some(o) => return Err(bad(cmd, format!("unknown corner `{o}` (rolling or g2)"))),
};
if !corner.is_rolling() && (!style.is_constant() || !fit.is_exact()) {
return Err(bad(cmd, "`corner: g2` is built for a constant-radius fillet (`type: constant`, `fit: exact`) only"));
}
let mut out = add_feature(
s,
p,
FeatureKind::Fillet {
edges: edges.into_iter().map(Position).collect(),
radius,
body: str_(p, "body").map(BodyName::from),
style,
fit,
corner,
},
)?;
// The radii the fillet used, when `fit: reduce` reduced it.
if let Some(id) = out.get("feature").and_then(Value::as_u64)
&& let Some(state) = s.model.result(id).map(|res| &res.state)
&& let Some(r) = state.fitted_radius(id)
{
out["radius_applied"] = serde_json::json!(r);
let each: Vec<Value> =
state.fitted_edges(id).unwrap_or_default().into_iter().map(|(p, r)| serde_json::json!({"edge": [p.x, p.y, p.z], "radius": r})).collect();
out["radii_applied"] = Value::Array(each);
}
Ok(out)
}
fn chamfer(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.chamfer";
let edges = edge_points(s, p, cmd)?;
let distance = req_expr(cmd, p, "distance")?;
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
let (distance2, angle) = (expr(p, "distance2"), expr(p, "angle"));
if let Some(d) = &distance2 {
check_expr(s, d, Kind::Length, cmd, "distance2")?;
}
if let Some(a) = &angle {
check_expr(s, a, Kind::Angle, cmd, "angle")?;
}
if distance2.is_some() && angle.is_some() {
return Err(bad(cmd, "give `distance2` or `angle`, not both"));
}
let flip = bool_(p, "flip").unwrap_or(false);
add_feature(
s,
p,
FeatureKind::Chamfer {
edges: edges.into_iter().map(Position).collect(),
distance,
distance2,
angle,
flip,
body: str_(p, "body").map(BodyName::from),
},
)
}
fn combine(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.combine";
let target = str_(p, "target").ok_or_else(|| bad(cmd, "`target` must be a body name"))?.to_string();
let tools = string_list(p, "tools");
if tools.is_empty() {
return Err(bad(cmd, "`tools` must list bodies"));
}
let op = match str_(p, "operation") {
None => Operation::Join,
Some(o) => Operation::parse(o).filter(|o| *o != Operation::NewBody).ok_or_else(|| bad(cmd, "operation must be join, cut or intersect"))?,
};
let mut out = add_feature(
s,
p,
FeatureKind::Combine {
target: target.clone().into(),
tools: tools.into_iter().map(BodyName).collect(),
operation: op,
keep_tools: bool_(p, "keep_tools").unwrap_or(false),
},
)?;
// New Component: the result goes into a component of its own.
if bool_(p, "new_component").unwrap_or(false) {
let name = str_(p, "component_name").map(str::to_string).unwrap_or_else(|| format!("{target} Combined"));
let c = s.execute("component.create", &json!({"name": name, "activate": false}))?;
super::component::transfer_constructor_result(s, &json!({"bodies": [target], "component": c["component"]}))?;
out["component"] = c["component"].clone();
}
Ok(out)
}
fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.move";
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
return Err(bad(cmd, "`bodies` must list bodies"));
}
let t = match p.get("translate") {
Some(Value::Array(a)) if a.len() == 3 => {
let mut out: [Expr; 3] = Default::default();
for (i, v) in a.iter().enumerate() {
let e = expr(&json!({"v": v}), "v").ok_or_else(|| bad(cmd, "translate entries must be numbers or expressions"))?;
check_expr(s, &e, Kind::Length, cmd, "translate")?;
if let Some(slot) = out.get_mut(i) {
*slot = e;
}
}
out
}
None => ["0".into(), "0".into(), "0".into()],
_ => return Err(bad(cmd, "`translate` must be [x, y, z]")),
};
let angle = expr(p, "angle");
if let Some(a) = &angle {
check_expr(s, a, Kind::Angle, cmd, "angle")?;
}
add_feature(
s,
p,
FeatureKind::Move {
bodies: bodies.into_iter().map(BodyName).collect(),
translate: t,
rotate_axis: p.get("axis").and_then(vec3).map(Dir),
angle,
copy: bool_(p, "copy").unwrap_or(false),
},
)
}
fn source_features(s: &Session, p: &Value, cmd: &str) -> Result<Vec<String>> {
let names = string_list(p, "features");
if names.is_empty() {
return Err(bad(cmd, "`features` must list features to copy"));
}
for n in &names {
if s.doc.find_feature(n).is_none() {
return Err(bad(cmd, format!("no feature `{n}`")));
}
}
Ok(names)
}
fn axis_line(p: &Value, cmd: &str) -> Result<(Vec3, Vec3)> {
match p.get("axis") {
Some(Value::String(a)) => match a.to_ascii_uppercase().as_str() {
"X" => Ok((Vec3::ZERO, Vec3::X)),
"Y" => Ok((Vec3::ZERO, Vec3::Y)),
"Z" => Ok((Vec3::ZERO, Vec3::Z)),
_ => Err(bad(cmd, "axis must be X, Y, Z or {origin, dir}")),
},
Some(o @ Value::Object(_)) => {
let origin = match o.get("origin") {
None => Vec3::ZERO,
Some(v) => vec3(v).ok_or_else(|| {
bad(cmd, "axis.origin must contain three finite numeric coordinates in millimetres; expressions are not supported")
})?,
};
let dir = o.get("dir").and_then(vec3).and_then(|d| d.normalized()).ok_or_else(|| bad(cmd, "axis needs a non-zero `dir`"))?;
Ok((origin, dir))
}
_ => Err(bad(cmd, "`axis` is required")),
}
}
/// A pattern feature, or for `components`, new occurrences of them at each placement.
fn finish_pattern(s: &mut Session, p: &Value, features: Vec<String>, pattern: solidcraft_doc::PatternKind, bodies: Vec<String>) -> Result<Value> {
let comps = string_list(p, "components");
if comps.is_empty() {
return add_feature(
s,
p,
FeatureKind::Pattern {
features: features.into_iter().map(FeatureName).collect(),
pattern,
bodies: bodies.into_iter().map(BodyName).collect(),
},
);
}
let cmd = "pattern (components)";
let mats = solidcraft_doc::pattern_matrices(&s.doc, &s.model.state(), &pattern)?;
let mut made = Vec::new();
for c in &comps {
let id = s.doc.find_component(c).filter(|id| *id != 0).ok_or_else(|| bad(cmd, format!("no component `{c}`")))?;
let src = s.doc.occurrence_of(id).cloned().ok_or_else(|| bad(cmd, format!("component `{c}` has no occurrence")))?;
for m in &mats {
made.push(s.doc_mut().add_occurrence(id, src.parent, solidcraft_doc::mat_mul(m, &src.transform))?);
}
}
Ok(json!({"occurrences": made}))
}
/// What a pattern copies: features (replayed), bodies (`bodies: [names]`), or components
/// (`components: [names]`, placed again as occurrences).
fn pattern_sources(s: &Session, p: &Value, cmd: &str) -> Result<(Vec<String>, Vec<String>)> {
if !string_list(p, "components").is_empty() {
return Ok((Vec::new(), Vec::new()));
}
let bodies = string_list(p, "bodies");
if bodies.is_empty() {
return Ok((source_features(s, p, cmd)?, Vec::new()));
}
let st = s.model.state();
if let Some(b) = bodies.iter().find(|b| st.body(b).is_none()) {
return Err(bad(cmd, format!("no body `{b}`")));
}
Ok((Vec::new(), bodies))
}
fn pattern_rect(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.pattern.rectangular";
let (features, bodies) = pattern_sources(s, p, cmd)?;
let dir1 = p.get("dir1").and_then(vec3).ok_or_else(|| bad(cmd, "`dir1` must be [x, y, z]"))?;
let count1 = req_expr(cmd, p, "count1")?;
let spacing1 = req_expr(cmd, p, "spacing1")?;
check_expr(s, &count1, Kind::Unitless, cmd, "count1")?;
check_expr(s, &spacing1, Kind::Length, cmd, "spacing1")?;
let dir2 = p.get("dir2").and_then(vec3);
let (count2, spacing2) = (expr(p, "count2"), expr(p, "spacing2"));
if let Some(c) = &count2 {
check_expr(s, c, Kind::Unitless, cmd, "count2")?;
}
if let Some(c) = &spacing2 {
check_expr(s, c, Kind::Length, cmd, "spacing2")?;
}
let pattern = solidcraft_doc::PatternKind::Rectangular {
dir1: Dir(dir1),
count1,
spacing1,
dir2: dir2.map(Dir),
count2,
spacing2,
alignment: super::alignment_param(p, "alignment", cmd)?,
alignment2: super::alignment_param(p, "alignment2", cmd)?,
reversed: super::reversed_param(p, "reversed", cmd)?,
reversed2: super::reversed_param(p, "reversed2", cmd)?,
};
finish_pattern(s, p, features, pattern, bodies)
}
fn pattern_circ(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.pattern.circular";
let (features, bodies) = pattern_sources(s, p, cmd)?;
let (origin, axis) = axis_line(p, cmd)?;
let count = req_expr(cmd, p, "count")?;
check_expr(s, &count, Kind::Unitless, cmd, "count")?;
let angle = expr(p, "angle").unwrap_or_else(|| "360 deg".into());
check_expr(s, &angle, Kind::Angle, cmd, "angle")?;
let pattern = solidcraft_doc::PatternKind::Circular {
origin: Position(origin),
axis: Dir(axis),
count,
angle,
alignment: super::alignment_param(p, "alignment", cmd)?,
reversed: super::reversed_param(p, "reversed", cmd)?,
};
finish_pattern(s, p, features, pattern, bodies)
}
fn pattern_path(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.pattern.path";
let (features, bodies) = pattern_sources(s, p, cmd)?;
let path_sketch = sketch_id(s, p.get("path_sketch").or_else(|| p.get("sketch")), cmd, "path_sketch")?;
let path = string_list(p, "path");
if path.is_empty() || path.len() > 1000 {
return Err(bad(cmd, "`path` must list 1…1000 curve ids"));
}
let count = req_expr(cmd, p, "count")?;
check_expr(s, &count, Kind::Unitless, cmd, "count")?;
let (spacing, extent) = match (expr(p, "spacing"), expr(p, "distance")) {
(Some(x), _) => (x, false),
(None, Some(x)) => (x, true),
(None, None) => return Err(bad(cmd, "give `spacing` (between instances) or `distance` (first to last)")),
};
check_expr(s, &spacing, Kind::Length, cmd, "spacing")?;
let orient = match str_(p, "orientation") {
None | Some("identical") => false,
Some("path" | "path_direction" | "along_path") => true,
Some(o) => return Err(bad(cmd, format!("unknown orientation `{o}` (identical or path)"))),
};
let flip = bool_(p, "flip").unwrap_or(false);
let pattern = solidcraft_doc::PatternKind::Path { path_sketch: SketchId(path_sketch), path, count, spacing, extent, flip, orient };
finish_pattern(s, p, features, pattern, bodies)
}
fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.mirror";
let bodies = string_list(p, "bodies");
let features = if bodies.is_empty() { source_features(s, p, cmd)? } else { Vec::new() };
let plane = plane_param(s, p.get("plane"), cmd)?;
let combine = bool_(p, "combine").unwrap_or(false);
add_feature(
s,
p,
FeatureKind::Mirror {
features: features.into_iter().map(FeatureName).collect(),
plane,
bodies: bodies.into_iter().map(BodyName).collect(),
combine,
},
)
}
/// The other name of an origin plane (`Top` for `XY`, `XY` for `Top`...), if `name` is one.
pub(super) fn origin_plane_alias(name: &str) -> Option<&'static str> {
match name.trim().to_ascii_uppercase().as_str() {
"XY" => Some("Top"),
"TOP" => Some("XY"),
"XZ" => Some("Front"),
"FRONT" => Some("XZ"),
"YZ" => Some("Right"),
"RIGHT" => Some("YZ"),
_ => None,
}
}
/// A construction plane may not take an origin plane's name (`XY`, `Top`...): a plane
/// parameter could then mean either.
pub(super) fn check_plane_name(name: &str, cmd: &str) -> Result<()> {
if solidcraft_geom::Plane::named(name.trim()).is_some() {
return Err(bad(
cmd,
format!("`{}` is reserved for an origin plane (XY, XZ, YZ, Top, Front, Right); give the construction plane another name", name.trim()),
));
}
Ok(())
}
/// A plane reference from a parameter: an origin plane, a construction plane (by name or
/// feature id; kept by name, so the feature follows the plane when it changes), or an
/// explicit plane `{origin, x_dir, y_dir}` / `{origin, normal}`. Every command that takes a
/// plane reads it here.
pub(super) fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<solidcraft_doc::PlaneRef> {
use solidcraft_doc::PlaneRef;
const WANT: &str = "XY, XZ, YZ, a construction plane name or id, or {origin, normal} (origin coordinates may be length expressions)";
let construction = |r: &str| match s.doc.find_feature(r) {
Some(f) if matches!(f.kind, FeatureKind::ConstructionPlane { .. }) => Ok(PlaneRef::Construction { name: f.name.clone().into() }),
Some(f) => Err(bad(cmd, format!("`{}` is not a construction plane (a plane is {WANT})", f.name))),
None => Err(bad(cmd, format!("no plane `{r}` (a plane is {WANT})"))),
};
match v {
Some(Value::String(n)) if solidcraft_geom::Plane::named(n.trim()).is_some() => {
if let Some(f) = s.doc.find_feature(n.trim())
&& matches!(f.kind, FeatureKind::ConstructionPlane { .. })
{
return Err(bad(
cmd,
format!(
"plane `{}` is ambiguous: an origin-plane alias and a construction plane; use `{}` for the origin plane, construction plane feature id {}, or an explicit {{origin, normal}} plane",
n.trim(),
origin_plane_alias(n).unwrap_or("XY"),
f.id
),
));
}
Ok(PlaneRef::Origin { name: n.trim().to_ascii_uppercase() })
}
Some(Value::String(n)) => construction(n.trim()),
Some(Value::Number(id)) => match id.as_u64() {
Some(id) => construction(&id.to_string()),
None => Err(bad(cmd, format!("`plane` must be {WANT}"))),
},
Some(o @ Value::Object(_)) => {
// The origin's coordinates are numbers or length expressions; expressions are kept,
// so the plane follows its parameters.
let origin = match o.get("origin") {
None => point_expr(Vec3::ZERO),
Some(_) => point_param(s, o, "origin", cmd)?.unwrap_or_else(|| point_expr(Vec3::ZERO)),
};
let at = |e: &str| s.doc.eval(e, Kind::Length).unwrap_or(0.0);
let here = Vec3::new(at(&origin[0]), at(&origin[1]), at(&origin[2]));
let pl = match (o.get("x_dir").and_then(vec3), o.get("y_dir").and_then(vec3), o.get("normal").and_then(vec3)) {
(Some(x), Some(y), _) => solidcraft_geom::Plane::new(here, x, y),
(_, _, Some(n)) => solidcraft_geom::Plane::from_normal(here, n),
_ => None,
};
let plane = pl.ok_or_else(|| bad(cmd, "plane needs x_dir and y_dir, or normal"))?;
if origin.iter().all(|e| solidcraft_doc::expr::is_literal(e)) {
return Ok(PlaneRef::Custom { plane });
}
Ok(PlaneRef::Placed { origin, x_dir: plane.x, y_dir: plane.y })
}
_ => Err(bad(cmd, format!("`plane` must be {WANT}"))),
}
}
fn plane_offset(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.offset";
let d = req_expr(cmd, p, "offset")?;
check_expr(s, &d, Kind::Length, cmd, "offset")?;
// A planar body face: attached exactly, as a sketch on that face is, so the plane follows it.
if let Some(face) = p.get("base").filter(|b| b.get("face").is_some()) {
let base = super::sketch::face::capture(s, face, cmd)?;
super::sketch::face::capture_positions(s, &base)?;
let kind = FeatureKind::ConstructionPlane { plane: solidcraft_doc::PlaneRef::Offset { base: Box::new(base), distance: d } };
// The capture is already in the authoring frame.
return add_feature_in(s, p, kind, false);
}
let base = plane_param(s, p.get("base"), cmd)?;
add_feature(s, p, FeatureKind::ConstructionPlane { plane: solidcraft_doc::PlaneRef::Offset { base: Box::new(base), distance: d } })
}
fn plane_angle(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "construct.plane.at_angle";
let base = plane_param(s, p.get("base"), cmd)?;
let (axis_origin, axis_dir) = axis_line(p, cmd)?;
let angle = req_expr(cmd, p, "angle")?;
check_expr(s, &angle, Kind::Angle, cmd, "angle")?;
add_feature(
s,
p,
FeatureKind::ConstructionPlane { plane: solidcraft_doc::PlaneRef::AtAngle { base: Box::new(base), axis_origin, axis_dir, angle } },
)
}
fn split_face(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.split_face";
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()) {
let tb = t.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`tool.body` must name a body"))?.to_string();
let point = t.get("point").and_then(crate::params::vec3).ok_or_else(|| bad(cmd, "`tool.point` must be [x, y, z] on the tool face"))?;
FeatureKind::SplitFace {
faces: faces.into_iter().map(FacePick).collect(),
body: body.map(BodyName),
plane: xy(),
tool: Some(solidcraft_doc::FaceAt { body: BodyName(tb), point: Position(point) }),
sketch: None,
}
} else if p.get("sketch").is_some_and(|v| !v.is_null()) {
let sketch = sketch_id(s, p.get("sketch"), cmd, "sketch")?;
FeatureKind::SplitFace {
faces: faces.into_iter().map(FacePick).collect(),
body: body.map(BodyName),
plane: xy(),
tool: None,
sketch: Some(SketchId(sketch)),
}
} else {
let plane = plane_param(s, p.get("plane"), cmd)?;
FeatureKind::SplitFace { faces: faces.into_iter().map(FacePick).collect(), body: body.map(BodyName), plane, tool: None, sketch: None }
};
add_feature(s, p, kind)
}
fn split_body(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.split_body";
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string();
if s.model.state().body(&body).is_none() {
return Err(bad(cmd, format!("no body `{body}`")));
}
// A straight sketch line as the tool: `sketch` and `line` (#1552).
if p.get("sketch").is_some_and(|v| !v.is_null()) || p.get("line").is_some_and(|v| !v.is_null()) {
let sketch = p.get("sketch").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "`sketch` must be a sketch feature id"))?;
let line = str_(p, "line").ok_or_else(|| bad(cmd, "`line` must name a line of the sketch"))?.to_string();
let st = s.model.state();
if st.sketch(sketch).is_none() {
return Err(bad(cmd, format!("no sketch {sketch}")));
}
solidcraft_doc::sketch_line_plane(&st, sketch, &line).map_err(|e| bad(cmd, e.to_string()))?;
return add_feature(
s,
p,
FeatureKind::Split {
body: BodyName(body),
plane: solidcraft_doc::PlaneRef::Origin { name: "XY".into() },
tool: None,
sketch: Some(solidcraft_doc::SketchId(sketch)),
line: Some(line),
},
);
}
// A face as the tool: `tool: {body, point}` (its surface extended); else a plane.
if let Some(t) = p.get("tool").filter(|t| !t.is_null()) {
let tb = t.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`tool.body` must name a body"))?.to_string();
let point = t.get("point").and_then(crate::params::vec3).ok_or_else(|| bad(cmd, "`tool.point` must be [x, y, z] on the tool face"))?;
let st = s.model.state();
let tbody = st.body(&tb).ok_or_else(|| bad(cmd, format!("no body `{tb}`")))?;
if solidcraft_doc::appearance::face_index_at(tbody, point).is_none() {
return Err(bad(cmd, "`tool.point` is not on a face of the tool body"));
}
return add_feature(
s,
p,
FeatureKind::Split {
body: BodyName(body),
plane: solidcraft_doc::PlaneRef::Origin { name: "XY".into() },
tool: Some(solidcraft_doc::FaceAt { body: BodyName(tb), point: Position(point) }),
sketch: None,
line: None,
},
);
}
let plane = plane_param(s, p.get("plane"), cmd)?;
add_feature(s, p, FeatureKind::Split { body: BodyName(body), plane, tool: None, sketch: None, line: None })
}
/// ISO 273 clearance hole diameters (close, normal, loose fit) for metric bolts.
fn clearance_diameter(size: &str, fit: &str) -> Option<f64> {
const TABLE: [(f64, f64, f64, f64); 21] = [
(1.6, 1.7, 1.8, 2.0),
(2.0, 2.2, 2.4, 2.6),
(2.5, 2.7, 2.9, 3.1),
(3.0, 3.2, 3.4, 3.6),
(4.0, 4.3, 4.5, 4.8),
(5.0, 5.3, 5.5, 5.8),
(6.0, 6.4, 6.6, 7.0),
(8.0, 8.4, 9.0, 10.0),
(10.0, 10.5, 11.0, 12.0),
(12.0, 13.0, 13.5, 14.5),
(14.0, 15.0, 15.5, 16.5),
(16.0, 17.0, 17.5, 18.5),
(20.0, 21.0, 22.0, 24.0),
(24.0, 25.0, 26.0, 28.0),
(30.0, 31.0, 33.0, 35.0),
(36.0, 37.0, 39.0, 42.0),
(42.0, 43.0, 45.0, 48.0),
(48.0, 50.0, 52.0, 56.0),
(56.0, 58.0, 62.0, 66.0),
(60.0, 62.0, 66.0, 70.0),
(64.0, 66.0, 70.0, 74.0),
];
let t = size.trim().trim_start_matches(['M', 'm']);
let d: f64 = t.split(['x', 'X']).next()?.trim().parse().ok()?;
let row = TABLE.iter().find(|r| (r.0 - d).abs() < 1e-9)?;
match fit {
"close" | "fine" => Some(row.1),
"normal" | "medium" => Some(row.2),
"loose" | "coarse" => Some(row.3),
_ => None,
}
}
fn hole(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.hole";
// At sketch points: the holes go perpendicular to the sketch and follow its points.
let points = match (p.get("sketch"), p.get("points"), p.get("position")) {
(Some(_), Some(_), _) | (Some(_), None, None) => {
let sketch = feature_sketch(s, p, cmd)?;
// No points named: every sketch point (standalone points and circle centres, not
// line or arc ends, not the origin).
let ids = if p.get("points").is_some() { string_list(p, "points") } else { sketch_hole_points(s, sketch) };
if ids.is_empty() || ids.len() > 10_000 {
return Err(bad(cmd, "`points` must list 1…10000 sketch point ids"));
}
let st = s.model.state();
let ss = st.sketch(sketch).ok_or_else(|| bad(cmd, "the sketch is not evaluated"))?;
for id in &ids {
if !ss.sketch.points.iter().any(|q| &q.id == id) {
return Err(bad(cmd, format!("no point `{id}` in the sketch")));
}
}
let n = ss.plane.normal();
let first =
ids.first().and_then(|id| ss.sketch.points.iter().find(|q| &q.id == id)).map(|q| ss.plane.to_world(q.pos)).unwrap_or_default();
let flip = p.get("flip").and_then(Value::as_bool).unwrap_or(false);
Some((solidcraft_doc::SketchPoints { sketch: SketchId(sketch), ids, flip }, first, -n))
}
_ => None,
};
let thread = str_(p, "thread").map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
if let Some(t) = &thread
&& solidcraft_doc::parse_metric_thread(t).is_none()
{
return Err(bad(cmd, format!("unknown thread `{t}` (ISO metric, like M6 or M6x0.75)")));
}
// A position given as length expressions (#1678), say `["leg_h*0.4", "width/2", "t"]`: kept,
// so the hole follows the parameters; drilled where they evaluate now.
let at: Option<solidcraft_doc::PointExpr> = match p.get("position").and_then(Value::as_array) {
Some(a) if points.is_none() && a.len() == 3 && a.iter().any(Value::is_string) => {
let mut coords = Vec::with_capacity(3);
for v in a {
let e = match v {
Value::String(x) => x.trim().to_string(),
Value::Number(n) => n.to_string(),
_ => return Err(bad(cmd, "`position` must be [x, y, z]: numbers or length expressions")),
};
check_expr(s, &e, Kind::Length, cmd, "position")?;
coords.push(solidcraft_doc::Expr::new(e));
}
let [x, y, z]: [solidcraft_doc::Expr; 3] = coords.try_into().map_err(|_| bad(cmd, "`position` must be [x, y, z]"))?;
Some([x, y, z])
}
_ => None,
};
let at_value = match &at {
Some([x, y, z]) => {
let c = |e: &solidcraft_doc::Expr| s.doc.eval(e, Kind::Length).map_err(|e| bad(cmd, format!("`position`: {e}")));
Some(Vec3::new(c(x)?, c(y)?, c(z)?)).filter(|v| v.is_finite())
}
None => None,
};
let position = match (&points, at_value.or_else(|| p.get("position").and_then(vec3))) {
(Some((_, first, _)), None) => *first,
(_, Some(x)) => x,
(None, None) => return Err(bad(cmd, "`position` must be [x, y, z], numbers or length expressions (or give `sketch` and `points`)")),
};
let direction = match (p.get("direction").and_then(vec3), &points) {
(None, Some((_, _, d))) => *d,
(d, _) => match d {
Some(d) => d.normalized().ok_or_else(|| bad(cmd, "`direction` must be non-zero"))?,
None => {
// Into the face the position lies on: against the outward normal of the face
// nearest it (planar or curved), as it faces out of its own body.
let st = s.model.state();
solidcraft_doc::hole_direction_at(&st, position).ok_or_else(|| bad(cmd, "no face at `position`; give `direction`"))?
}
},
};
// The diameter: given, or the tap drill of the thread (major − pitch), or an ISO 273
// clearance hole for a bolt size (close, normal or loose fit).
let diameter = match (expr(p, "diameter"), &thread, str_(p, "clearance")) {
(Some(d), _, _) => d,
(None, _, Some(c)) => {
let fit = str_(p, "fit").unwrap_or("normal").to_ascii_lowercase();
let d = clearance_diameter(c, &fit).ok_or_else(|| bad(cmd, format!("no clearance hole for `{c}` ({fit} fit): M1.6 to M64")))?;
Expr::new(format!("{d} mm"))
}
(None, Some(t), None) => {
let (major, pitch) = solidcraft_doc::parse_metric_thread(t).ok_or_else(|| bad(cmd, "thread"))?;
Expr::new(format!("{} mm", ((major - pitch) * 1000.0).round() / 1000.0))
}
(None, None, None) => return Err(bad(cmd, "needs `diameter`, `thread` (a tapped hole) or `clearance` (a bolt size)")),
};
check_expr(s, &diameter, Kind::Length, cmd, "diameter")?;
let depth = expr(p, "depth");
if let Some(d) = &depth {
check_expr(s, d, Kind::Length, cmd, "depth")?;
}
let get = |k: &str| -> Result<Expr> {
let e = req_expr(cmd, p, k)?;
Ok(e)
};
let kind = match str_(p, "type").unwrap_or("simple") {
"simple" => match expr(p, "tip_angle") {
Some(t) if depth.is_some() => solidcraft_doc::HoleKind::Drilled { tip_angle: t },
_ => solidcraft_doc::HoleKind::Simple,
},
"drilled" => solidcraft_doc::HoleKind::Drilled { tip_angle: expr(p, "tip_angle").unwrap_or_else(|| "118 deg".into()) },
"counterbore" => solidcraft_doc::HoleKind::Counterbore { cb_diameter: get("cb_diameter")?, cb_depth: get("cb_depth")? },
"countersink" => solidcraft_doc::HoleKind::Countersink {
cs_diameter: get("cs_diameter")?,
cs_angle: expr(p, "cs_angle").unwrap_or_else(|| "90 deg".into()),
},
other => return Err(bad(cmd, format!("unknown hole type `{other}`"))),
};
let to = match p.get("to") {
Some(v) => Some(vec3(v).ok_or_else(|| bad(cmd, "`to` must be a point [x, y, z] on the face to drill to"))?),
None => None,
};
// A hole picked on a body of another component (a moved one, shown where it is placed) cuts
// that body: the pick is already in that body's frame, and the component's own bodies alone
// would leave nothing to cut.
let mut p = p.clone();
if points.is_none()
&& p.get("participants").is_none()
&& let Some(body) = body_of_other_component_at(s, position)
{
p["participants"] = json!([body]);
}
add_feature(
s,
&p,
FeatureKind::Hole {
position: FacePick(position),
at,
direction: Dir(direction),
diameter,
depth,
hole: kind,
points: points.map(|x| x.0),
thread,
to: to.map(FacePick),
},
)
}
/// The body whose face is nearest `p` (within its pick tolerance), when it belongs to another
/// component than the active one.
fn body_of_other_component_at(s: &Session, p: Vec3) -> Option<String> {
let st = s.model.state();
let (_, body) = st
.bodies
.iter()
.filter(|b| !b.body.is_mesh())
.filter_map(|b| {
let (d, _) = solidcraft_doc::appearance::nearest_face(b, p)?;
(d <= solidcraft_doc::appearance::pick_tolerance(b)).then_some((d, b))
})
.min_by(|a, b| a.0.total_cmp(&b.0))?;
(s.doc.model_body_component(body) != s.active_component).then(|| body.name.clone())
}
/// The points of a sketch a hole drills at when only the sketch is given.
fn sketch_hole_points(s: &Session, sketch: u64) -> Vec<String> {
let st = s.model.state();
let Some(ss) = st.sketch(sketch) else { return Vec::new() };
let mut ends: Vec<usize> = Vec::new();
for c in &ss.sketch.curves {
match &c.kind {
solidcraft_sketch::CurveKind::Circle { .. } => {}
solidcraft_sketch::CurveKind::Arc { a, b, .. } | solidcraft_sketch::CurveKind::Line { a, b } => ends.extend([*a, *b]),
solidcraft_sketch::CurveKind::Ellipse { m, .. } => ends.push(*m),
solidcraft_sketch::CurveKind::Spline { pts, .. } => ends.extend(pts.iter().copied()),
solidcraft_sketch::CurveKind::Conic { a, b, apex, .. } => ends.extend([*a, *b, *apex]),
}
}
ss.sketch.points.iter().enumerate().filter(|(i, p)| p.id != "origin" && !ends.contains(i)).map(|(_, p)| p.id.clone()).collect()
}
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| 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(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();
if !matches!(direction.as_str(), "" | "inside" | "outside" | "both") {
return Err(bad(cmd, "`direction` must be inside, outside or both"));
}
add_feature(
s,
p,
FeatureKind::Shell {
faces: faces.into_iter().map(FacePick).collect(),
thickness,
body: str_(p, "body").map(BodyName::from),
direction: if direction == "inside" { String::new() } else { direction },
tangent_chain: bool_(p, "tangent_chain").unwrap_or(false),
},
)
}
fn draft(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.draft";
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)?;
let (vals, _) = s.doc.param_values();
let pl = s.doc.resolve_plane_in(&vals, &neutral, 0, Some(&s.model.state()))?;
let pull = p.get("pull").and_then(vec3).unwrap_or_else(|| pl.normal());
add_feature(
s,
p,
FeatureKind::Draft {
faces: faces.into_iter().map(FacePick).collect(),
angle,
neutral,
pull: Dir(pull),
body: str_(p, "body").map(BodyName::from),
},
)
}
pub(super) fn sketch_id(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -> Result<u64> {
let key = match v {
Some(Value::Number(n)) => n.to_string(),
Some(Value::String(x)) => x.clone(),
_ => return Err(bad(cmd, format!("`{what}` must be a sketch id or name"))),
};
match s.doc.find_feature(&key) {
Some(f) if matches!(f.kind, FeatureKind::Sketch { .. }) => Ok(f.id),
_ => Err(bad(cmd, format!("no sketch `{key}`"))),
}
}
fn sweep(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.sweep";
let sketch = feature_sketch(s, p, cmd)?;
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 (orientation, twist, scale) = sweep_options(s, p, cmd)?;
add_feature(
s,
p,
FeatureKind::Sweep {
sketch: SketchId(sketch),
profiles: profiles(p, cmd)?,
path_sketch: SketchId(path_sketch),
path,
orientation,
twist,
scale,
operation: operation(p, cmd)?,
targets: string_list(p, "targets").into_iter().map(BodyName).collect(),
},
)
}
/// A sweep's `orientation`, `twist` and `scale` (solid.sweep and surface.sweep), checked: an
/// orientation the sweep knows, a twist within four turns, an end scale within 0.01…100.
pub(super) fn sweep_options(s: &Session, p: &Value, cmd: &str) -> Result<(SweepOrientation, Option<Expr>, Option<Expr>)> {
let orientation = match p.get("orientation") {
None | Some(Value::Null) => SweepOrientation::Perpendicular,
Some(Value::String(o)) if o.eq_ignore_ascii_case("perpendicular") => SweepOrientation::Perpendicular,
Some(Value::String(o)) if o.eq_ignore_ascii_case("parallel") => SweepOrientation::Parallel,
Some(v) => {
let d = vec3(v)
.or_else(|| v.get("direction").and_then(vec3))
.ok_or_else(|| bad(cmd, "`orientation` must be perpendicular, parallel or a direction [x, y, z]"))?;
if !(d.len() > 1e-9) {
return Err(bad(cmd, "the `orientation` direction must not be zero"));
}
SweepOrientation::Direction(Dir(d))
}
};
let twist = match p.get("twist") {
None | Some(Value::Null) => None,
Some(_) => {
let t = expr(p, "twist").ok_or_else(|| bad(cmd, "`twist` must be an angle or an expression"))?;
let v = s.doc.eval(&t, Kind::Angle).map_err(|e| bad(cmd, format!("twist: {e}")))?;
if !(v.is_finite() && v.abs() <= solidcraft_kernel::SweepControl::MAX_TWIST) {
return Err(bad(cmd, "`twist` must be within four turns (1440 deg)"));
}
Some(t)
}
};
let scale = match p.get("scale") {
None | Some(Value::Null) => None,
Some(_) => {
let k = expr(p, "scale").ok_or_else(|| bad(cmd, "`scale` must be a number or an expression"))?;
let v = s.doc.eval(&k, Kind::Unitless).map_err(|e| bad(cmd, format!("scale: {e}")))?;
let range = solidcraft_kernel::SweepControl::MIN_SCALE..=solidcraft_kernel::SweepControl::MAX_SCALE;
if !(v.is_finite() && range.contains(&v)) {
return Err(bad(cmd, "`scale` (the end scale) must be between 0.01 and 100"));
}
Some(k)
}
};
Ok((orientation, twist, scale))
}
fn loft(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.loft";
let list = p.get("sections").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`sections` must be a list"))?;
if list.len() < 2 || list.len() > 50 {
return Err(bad(cmd, "a loft needs 2…50 sections"));
}
let mut sections = Vec::new();
for sec in list {
let sketch = sketch_id(s, sec.get("sketch"), cmd, "sections[].sketch")?;
// A sketch point closes the loft to an apex.
if let Some(pt) = str_(sec, "point") {
let st = s.model.state();
if !st.sketch(sketch).is_some_and(|ss| ss.sketch.points.iter().any(|q| q.id == pt)) {
return Err(bad(cmd, format!("no point `{pt}` in the section's sketch")));
}
sections.push(solidcraft_doc::LoftSection {
sketch: SketchId(sketch),
profiles: solidcraft_doc::ProfileSel::All,
point: Some(pt.to_string()),
});
continue;
}
sections.push(solidcraft_doc::LoftSection { sketch: SketchId(sketch), profiles: profiles(sec, cmd)?, point: None });
}
add_feature(
s,
p,
FeatureKind::Loft { sections, operation: operation(p, cmd)?, targets: string_list(p, "targets").into_iter().map(BodyName).collect() },
)
}
fn thread(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.thread";
let face = p
.get("face")
.and_then(|v| vec3(v).or_else(|| v.get("point").and_then(vec3)))
.ok_or_else(|| bad(cmd, "`face` must be a point [x, y, z] on a cylindrical face"))?;
let designation = str_(p, "designation").map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
if let Some(t) = &designation
&& solidcraft_doc::parse_metric_thread(t).is_none()
{
return Err(bad(cmd, format!("unknown thread `{t}` (ISO metric, like M8 or M8x1)")));
}
let length = expr(p, "length");
if let Some(l) = &length {
check_expr(s, l, Kind::Length, cmd, "length")?;
}
let modeled = p.get("modeled").and_then(Value::as_bool).unwrap_or(false);
let v = add_feature(s, p, FeatureKind::Thread { face: FacePick(face), designation, length, modeled })?;
let st = s.model.state();
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 = "solid.pipe";
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: SketchId(path_sketch),
path,
diameter,
wall,
operation: operation(p, cmd)?,
targets: string_list(p, "targets").into_iter().map(BodyName).collect(),
},
)
}
fn bounding_solid(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.bounding_solid";
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").into_iter().map(BodyName).collect(), margin })
}
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.scale";
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().into(), e[1].clone().into(), e[2].clone().into()])
}
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: bodies.into_iter().map(BodyName).collect(), origin: Position(origin), factor, factors })
}
fn delete_face(s: &mut Session, p: &Value) -> Result<Value> {
let faces = face_points(s, p, "face.delete")?;
let heal = bool_(p, "heal").unwrap_or(true);
add_feature(s, p, FeatureKind::DeleteFace { faces: faces.into_iter().map(FacePick).collect(), body: str_(p, "body").map(BodyName::from), heal })
}
fn offset_face(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "solid.offset_face";
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: faces.into_iter().map(FacePick).collect(), distance, body: str_(p, "body").map(BodyName::from) },
)
}
#[cfg(test)]
#[path = "patterns_tests.rs"]
mod patterns_tests;
#[cfg(test)]
#[path = "prism_local_tests.rs"]
mod prism_local_tests;
#[cfg(test)]
#[path = "revolve_convention_tests.rs"]
mod revolve_convention_tests;
#[cfg(test)]
#[path = "sweep_options_tests.rs"]
mod sweep_options_tests;
#[cfg(test)]
#[path = "offset_topology_tests.rs"]
mod offset_topology_tests;
#[cfg(test)]
#[path = "offset_identity_tests.rs"]
mod offset_identity_tests;
#[cfg(test)]
#[path = "offset_edit_guard_tests.rs"]
mod offset_edit_guard_tests;
#[cfg(test)]
#[path = "offset_plane_face_tests.rs"]
mod offset_plane_face_tests;
#[cfg(test)]
#[path = "lineage_face_tests.rs"]
mod lineage_face_tests;
#[cfg(test)]
#[path = "stacked_face_tests.rs"]
mod stacked_face_tests;
#[cfg(test)]
#[path = "solid_move_copy_tests.rs"]
mod solid_move_copy_tests;
#[cfg(test)]
#[path = "move_in_sketch_tests.rs"]
mod move_in_sketch_tests;
#[cfg(test)]
#[path = "thin_extrude_tests.rs"]
mod thin_extrude_tests;
#[cfg(test)]
#[path = "extrude_convention_tests.rs"]
mod extrude_convention_tests;
/// `solid.fillet corner?: rolling|g2`: the default keeps the rolling ball's sphere corner, `g2`
/// is accepted and refuses (item 10's patch is built but not sewn in yet, see `corner_g2`), and an
/// unknown word is refused.
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod corner_tests {
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, id: &str, p: &Value) -> Value {
s.execute(id, p).unwrap_or_else(|e| panic!("{id}: {e}"))
}
/// A 20 mm box with its three edges at one corner, filleted by 4 with `corner`.
fn cornered(corner: Option<&str>) -> (Session, Value) {
let mut s = Session::default();
run(&mut s, "solid.box", &json!({"length": 20, "width": 20, "height": 20, "body_name": "B"}));
let mut p = json!({"edges": [[10, 0, 0], [0, 10, 0], [0, 0, 10]], "radius": 4});
if let Some(c) = corner {
p["corner"] = json!(c);
}
let reply = run(&mut s, "solid.fillet", &p);
(s, reply)
}
#[test]
fn the_default_corner_is_the_rolling_ball_and_g2_refuses_clearly() {
let (default, reply) = cornered(None);
assert_eq!(reply["warning"], json!(null), "{reply}");
let (rolling, _) = cornered(Some("rolling"));
let ball = default.world_state().body("B").unwrap().body.clone();
let same = rolling.world_state().body("B").unwrap().body.clone();
// `corner: rolling` is the default: the same body, to the last bit of its area.
assert_eq!(solidcraft_kernel::measure(&ball).unwrap().area, solidcraft_kernel::measure(&same).unwrap().area);
assert!(solidcraft_kernel::measure(&ball).unwrap().volume > 7000.0);
// `corner: g2`: accepted, and refused with a clear message until the patch sews in.
let mut s = Session::default();
run(&mut s, "solid.box", &json!({"length": 20, "width": 20, "height": 20, "body_name": "B"}));
let err =
s.execute("solid.fillet", &json!({"edges": [[10, 0, 0], [0, 10, 0], [0, 0, 10]], "radius": 4, "corner": "g2"})).unwrap_err().to_string();
eprintln!("corner g2: {err}");
assert!(err.contains("not supported yet:") && err.contains("G2 corner"), "{err}");
// An unknown word, and a style the G2 corner has no patch for, are refused as parameters.
assert!(s.execute("solid.fillet", &json!({"edges": [[10, 0, 0]], "radius": 4, "corner": "round"})).is_err());
assert!(s.execute("solid.fillet", &json!({"edges": [[10, 0, 0]], "radius": 4, "corner": "g2", "type": "chord"})).is_err());
assert!(s.execute("solid.fillet", &json!({"edges": [[10, 0, 0]], "radius": 4, "corner": "g2", "fit": "reduce"})).is_err());
}
}
#[cfg(test)]
#[path = "fillet_connected_rounds_tests.rs"]
mod fillet_connected_rounds_tests;