M1: rectangular/circular patterns and mirror of features, construction planes (offset, at angle), split body by plane, chamfers by mitred cutters
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b85ccd62cf
commit
1467ad1d89
11 files changed
+721
-32
No files matched your search
@@ -209,6 +209,41 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
|
||||
"sketch": profile_sketch(f), "profiles": profiles_of(f), "axis": axis,
|
||||
"angle": f.get("angle").cloned().unwrap_or(json!("360 deg")), "operation": op(f), "name": name, "body_names": bodies}}));
|
||||
}
|
||||
Some("rectangular_pattern") => {
|
||||
let d1 = f.get("direction1").cloned().unwrap_or(Value::Null);
|
||||
let d2 = f.get("direction2").cloned().unwrap_or(Value::Null);
|
||||
let mut p = json!({"features": f.get("features"), "dir1": d1.get("axis"), "count1": d1.get("count"), "spacing1": d1.get("spacing"), "name": name});
|
||||
if !d2.is_null() {
|
||||
p["dir2"] = d2.get("axis").cloned().unwrap_or(Value::Null);
|
||||
p["count2"] = d2.get("count").cloned().unwrap_or(Value::Null);
|
||||
p["spacing2"] = d2.get("spacing").cloned().unwrap_or(Value::Null);
|
||||
}
|
||||
out.push(json!({"command": "PatternRectangular", "params": p}));
|
||||
}
|
||||
Some("circular_pattern") => {
|
||||
let axis = f.get("axis").map(|a| json!({"origin": a.get("origin").cloned().unwrap_or(json!([0, 0, 0])), "dir": a.get("dir")})).unwrap_or(Value::Null);
|
||||
out.push(json!({"command": "PatternCircular", "params": {"features": f.get("features"), "axis": axis, "count": f.get("count"), "angle": f.get("total_angle").cloned().unwrap_or(json!(360)), "name": name}}));
|
||||
}
|
||||
Some("construction_plane") => match f.get("method").and_then(Value::as_str) {
|
||||
Some("offset") => out.push(json!({"command": "ConstructionPlaneOffsetFromPlaneCommand", "params": {"base": f.get("base"), "offset": f.get("offset"), "name": name}})),
|
||||
_ => {
|
||||
// Use the resulting plane as recorded.
|
||||
let (o, n) = (f.get("result_origin").cloned().unwrap_or(json!([0, 0, 0])), f.get("result_normal").cloned().unwrap_or(Value::Null));
|
||||
if n.is_null() {
|
||||
return Err(format!("recipe: construction plane method {:?} without result_normal", f.get("method")));
|
||||
}
|
||||
out.push(json!({"command": "ConstructionPlaneOffsetFromPlaneCommand", "params": {"base": {"origin": o, "normal": n}, "offset": 0, "name": name}}));
|
||||
}
|
||||
},
|
||||
Some("split_body") => out.push(json!({"command": "FusionSplitBodyCommand", "params": {"body": f.get("body"), "plane": f.get("tool"), "name": name}})),
|
||||
Some("mirror") => {
|
||||
let pl = f.get("plane").cloned().unwrap_or(Value::Null);
|
||||
let plane = match pl.get("ref").and_then(Value::as_str) {
|
||||
Some(r @ ("XY" | "XZ" | "YZ")) => json!(r),
|
||||
_ => json!({"origin": pl.get("origin").cloned().unwrap_or(json!([0, 0, 0])), "normal": pl.get("normal")}),
|
||||
};
|
||||
out.push(json!({"command": "MirrorCommand", "params": {"features": f.get("features"), "plane": plane, "name": name}}));
|
||||
}
|
||||
Some("fillet") => {
|
||||
out.push(json!({"command": "FusionFilletEdgesCommand", "params": {"edges": edge_points(f), "radius": f.get("radius"), "name": name}}))
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@ pub enum PlaneRef {
|
||||
Custom { plane: Plane },
|
||||
/// Another plane offset along its normal by an expression.
|
||||
Offset { base: Box<PlaneRef>, distance: String },
|
||||
/// Another plane rotated about a line (in world coordinates) by an angle expression.
|
||||
AtAngle { base: Box<PlaneRef>, axis_origin: Vec3, axis_dir: Vec3, angle: String },
|
||||
/// A construction plane feature (by name).
|
||||
Construction { name: String },
|
||||
}
|
||||
|
||||
/// Which closed profiles of a sketch a feature uses.
|
||||
@@ -202,6 +206,25 @@ pub enum FeatureKind {
|
||||
#[serde(default)]
|
||||
keep_tools: bool,
|
||||
},
|
||||
/// Copies of other features' results (rectangular or circular).
|
||||
Pattern {
|
||||
features: Vec<String>,
|
||||
pattern: PatternKind,
|
||||
},
|
||||
/// Mirror copies of other features' results in a plane.
|
||||
Mirror {
|
||||
features: Vec<String>,
|
||||
plane: PlaneRef,
|
||||
},
|
||||
/// A construction plane (sketch placement, split tool).
|
||||
ConstructionPlane {
|
||||
plane: PlaneRef,
|
||||
},
|
||||
/// Split a body with a plane into two bodies.
|
||||
Split {
|
||||
body: String,
|
||||
plane: PlaneRef,
|
||||
},
|
||||
Move {
|
||||
bodies: Vec<String>,
|
||||
translate: [String; 3],
|
||||
@@ -212,6 +235,47 @@ pub enum FeatureKind {
|
||||
},
|
||||
}
|
||||
|
||||
/// Pattern layouts.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum PatternKind {
|
||||
Rectangular {
|
||||
dir1: Vec3,
|
||||
count1: String,
|
||||
spacing1: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
dir2: Option<Vec3>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
count2: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
spacing2: Option<String>,
|
||||
},
|
||||
Circular {
|
||||
origin: Vec3,
|
||||
axis: Vec3,
|
||||
count: String,
|
||||
/// Total angle (360 deg = evenly around).
|
||||
angle: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn plane_exprs<'a>(p: &'a PlaneRef, v: &mut Vec<&'a str>) {
|
||||
let mut p = p;
|
||||
for _ in 0..64 {
|
||||
match p {
|
||||
PlaneRef::Offset { base, distance } => {
|
||||
v.push(distance);
|
||||
p = base;
|
||||
}
|
||||
PlaneRef::AtAngle { base, angle, .. } => {
|
||||
v.push(angle);
|
||||
p = base;
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn z_axis() -> Vec3 {
|
||||
Vec3::Z
|
||||
}
|
||||
@@ -230,6 +294,11 @@ impl FeatureKind {
|
||||
FeatureKind::Sphere { .. } => "SphereFeature",
|
||||
FeatureKind::Torus { .. } => "TorusFeature",
|
||||
FeatureKind::Combine { .. } => "CombineFeature",
|
||||
FeatureKind::Pattern { pattern: PatternKind::Rectangular { .. }, .. } => "RectangularPatternFeature",
|
||||
FeatureKind::Pattern { .. } => "CircularPatternFeature",
|
||||
FeatureKind::Mirror { .. } => "MirrorFeature",
|
||||
FeatureKind::ConstructionPlane { .. } => "ConstructionPlane",
|
||||
FeatureKind::Split { .. } => "SplitBodyFeature",
|
||||
FeatureKind::Move { .. } => "MoveFeature",
|
||||
}
|
||||
}
|
||||
@@ -246,6 +315,11 @@ impl FeatureKind {
|
||||
FeatureKind::Sphere { .. } => "Sphere",
|
||||
FeatureKind::Torus { .. } => "Torus",
|
||||
FeatureKind::Combine { .. } => "Combine",
|
||||
FeatureKind::Pattern { pattern: PatternKind::Rectangular { .. }, .. } => "RectangularPattern",
|
||||
FeatureKind::Pattern { .. } => "CircularPattern",
|
||||
FeatureKind::Mirror { .. } => "Mirror",
|
||||
FeatureKind::ConstructionPlane { .. } => "Plane",
|
||||
FeatureKind::Split { .. } => "Split",
|
||||
FeatureKind::Move { .. } => "Move",
|
||||
}
|
||||
}
|
||||
@@ -253,12 +327,8 @@ impl FeatureKind {
|
||||
pub fn expressions(&self) -> Vec<&str> {
|
||||
let mut v: Vec<&str> = Vec::new();
|
||||
match self {
|
||||
FeatureKind::Sketch { plane, .. } => {
|
||||
let mut p = plane;
|
||||
while let PlaneRef::Offset { base, distance } = p {
|
||||
v.push(distance);
|
||||
p = base;
|
||||
}
|
||||
FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Split { plane, .. } => {
|
||||
plane_exprs(plane, &mut v)
|
||||
}
|
||||
FeatureKind::Extrude { extent, .. } => {
|
||||
v.push(&extent.distance);
|
||||
@@ -280,6 +350,15 @@ impl FeatureKind {
|
||||
FeatureKind::Sphere { radius, .. } => v.push(radius),
|
||||
FeatureKind::Torus { major, minor, .. } => v.extend([major.as_str(), minor]),
|
||||
FeatureKind::Combine { .. } => {}
|
||||
FeatureKind::Pattern { pattern, .. } => match pattern {
|
||||
PatternKind::Rectangular { count1, spacing1, count2, spacing2, .. } => {
|
||||
v.extend([count1.as_str(), spacing1]);
|
||||
v.extend(count2.iter().map(String::as_str));
|
||||
v.extend(spacing2.iter().map(String::as_str));
|
||||
}
|
||||
PatternKind::Circular { count, angle, .. } => v.extend([count.as_str(), angle]),
|
||||
},
|
||||
FeatureKind::Mirror { plane, .. } => plane_exprs(plane, &mut v),
|
||||
FeatureKind::Move { translate, angle, .. } => {
|
||||
v.extend(translate.iter().map(String::as_str));
|
||||
if let Some(a) = angle {
|
||||
@@ -561,6 +640,18 @@ impl Document {
|
||||
let b = self.resolve_plane(vals, base, depth + 1)?;
|
||||
Ok(b.offset(Self::eval_in(vals, distance, Kind::Length)?))
|
||||
}
|
||||
PlaneRef::AtAngle { base, axis_origin, axis_dir, angle } => {
|
||||
let b = self.resolve_plane(vals, base, depth + 1)?;
|
||||
let a = Self::eval_in(vals, angle, Kind::Angle)?;
|
||||
let d = axis_dir.normalized().ok_or_else(|| DocError::Invalid("rotation axis".into()))?;
|
||||
let rot = |v: Vec3| v * a.cos() + d.cross(v) * a.sin() + d * (d.dot(v) * (1.0 - a.cos()));
|
||||
let o = *axis_origin + rot(b.origin - *axis_origin);
|
||||
Plane::new(o, rot(b.x), rot(b.y)).ok_or_else(|| DocError::Invalid("degenerate plane".into()))
|
||||
}
|
||||
PlaneRef::Construction { name } => match self.find_feature(name).map(|f| &f.kind) {
|
||||
Some(FeatureKind::ConstructionPlane { plane }) => self.resolve_plane(vals, plane, depth + 1),
|
||||
_ => Err(DocError::Unknown(format!("construction plane `{name}`"))),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+203
-20
@@ -464,21 +464,9 @@ fn revolve_axis(ss: &SolvedSketch, axis: &AxisRef) -> Result<(Vec2, Vec2)> {
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
|
||||
/// The tool bodies a feature adds or removes (extrude, revolve, primitives), in the current state.
|
||||
fn feature_tools(vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -> Result<Vec<Body>> {
|
||||
match &f.kind {
|
||||
FeatureKind::Sketch { plane, sketch } => {
|
||||
let plane = doc.resolve_plane(vals, plane, 0)?;
|
||||
let mut sk = sketch.clone();
|
||||
doc.apply_dimension_values(vals, &mut sk)?;
|
||||
let report = solve(&mut sk);
|
||||
if !report.ok() {
|
||||
*warning = Some(format!("the sketch constraints conflict ({})", report.failing.join(", ")));
|
||||
}
|
||||
let profiles = find_profiles(&sk);
|
||||
st.sketches.retain(|s| s.feature != f.id);
|
||||
st.sketches.push(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles });
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone();
|
||||
let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
@@ -490,9 +478,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
}
|
||||
let sign = if extent.direction == crate::Direction::Negative { -1.0 } else { 1.0 };
|
||||
let (len, sign) = if d < 0.0 { (-d, -sign) } else { (d, sign) };
|
||||
let tools =
|
||||
regions.iter().map(|r| kernel::extrude_tapered(&ss.plane, r, len, sign, taper)).collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
return apply_op(st, f, tools, *operation, targets);
|
||||
return Ok(regions
|
||||
.iter()
|
||||
.map(|r| kernel::extrude_tapered(&ss.plane, r, len, sign, taper))
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?);
|
||||
}
|
||||
let off = match &extent.start_offset {
|
||||
Some(e) => val(vals, e, Kind::Length)?,
|
||||
@@ -511,16 +500,210 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
let (l2, h2) = extend_for_coplanar(st, &ss.plane, r, lo + off, hi + off, *operation, targets);
|
||||
tools.extend(kernel::extrude(&ss.plane, std::slice::from_ref(r), l2, h2)?);
|
||||
}
|
||||
apply_op(st, f, tools, *operation, targets)
|
||||
Ok(tools)
|
||||
}
|
||||
FeatureKind::Revolve { sketch, profiles, axis, angle, operation, targets } => {
|
||||
FeatureKind::Revolve { sketch, profiles, axis, angle, .. } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone();
|
||||
let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
let (o, d) = revolve_axis(&ss, axis)?;
|
||||
let ang = val(vals, angle, Kind::Angle)?;
|
||||
let tools = kernel::revolve(&ss.plane, ®ions, o, d, ang)?;
|
||||
Ok(kernel::revolve(&ss.plane, ®ions, o, d, ang)?)
|
||||
}
|
||||
FeatureKind::Box { corner, length, width, height, .. } => {
|
||||
let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?);
|
||||
Ok(vec![kernel::box_solid(*corner, *corner + s)?])
|
||||
}
|
||||
FeatureKind::Cylinder { base, axis, radius, height, .. } => {
|
||||
Ok(vec![kernel::cylinder(*base, *axis, val(vals, radius, Kind::Length)?, val(vals, height, Kind::Length)?)?])
|
||||
}
|
||||
FeatureKind::Sphere { center, radius, .. } => Ok(vec![kernel::sphere(*center, val(vals, radius, Kind::Length)?)?]),
|
||||
FeatureKind::Torus { center, major, minor, .. } => {
|
||||
Ok(vec![kernel::torus(*center, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?])
|
||||
}
|
||||
_ => Err(DocError::Invalid(format!("{} cannot be patterned or mirrored yet", f.name))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Operation and targets of a feature that makes tools.
|
||||
fn feature_op(f: &Feature) -> (Operation, Vec<String>) {
|
||||
match &f.kind {
|
||||
FeatureKind::Extrude { operation, targets, .. } | FeatureKind::Revolve { operation, targets, .. } => (*operation, targets.clone()),
|
||||
FeatureKind::Box { operation, .. }
|
||||
| FeatureKind::Cylinder { operation, .. }
|
||||
| FeatureKind::Sphere { operation, .. }
|
||||
| FeatureKind::Torus { operation, .. } => (*operation, Vec::new()),
|
||||
_ => (Operation::NewBody, Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
type Mat = [[f64; 4]; 4];
|
||||
|
||||
fn translation(t: Vec3) -> Mat {
|
||||
[[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [t.x, t.y, t.z, 1.0]]
|
||||
}
|
||||
|
||||
/// Rotation by `ang` about the line through `o` with direction `a` (column-major).
|
||||
fn rotation(o: Vec3, a: Vec3, ang: f64) -> Mat {
|
||||
let a = a.normalized().unwrap_or(Vec3::Z);
|
||||
let (c, s) = (ang.cos(), ang.sin());
|
||||
let t = 1.0 - c;
|
||||
let r = [
|
||||
[t * a.x * a.x + c, t * a.x * a.y + s * a.z, t * a.x * a.z - s * a.y],
|
||||
[t * a.x * a.y - s * a.z, t * a.y * a.y + c, t * a.y * a.z + s * a.x],
|
||||
[t * a.x * a.z + s * a.y, t * a.y * a.z - s * a.x, t * a.z * a.z + c],
|
||||
];
|
||||
// p' = R (p - o) + o
|
||||
let ro = Vec3::new(
|
||||
r[0][0] * o.x + r[1][0] * o.y + r[2][0] * o.z,
|
||||
r[0][1] * o.x + r[1][1] * o.y + r[2][1] * o.z,
|
||||
r[0][2] * o.x + r[1][2] * o.y + r[2][2] * o.z,
|
||||
);
|
||||
let tr = o - ro;
|
||||
[[r[0][0], r[0][1], r[0][2], 0.0], [r[1][0], r[1][1], r[1][2], 0.0], [r[2][0], r[2][1], r[2][2], 0.0], [tr.x, tr.y, tr.z, 1.0]]
|
||||
}
|
||||
|
||||
/// Reflection in a plane (column-major).
|
||||
fn mirror_matrix(pl: &Plane) -> Mat {
|
||||
let n = pl.normal();
|
||||
let d = pl.origin.dot(n);
|
||||
let m = |i: usize, j: usize| {
|
||||
let (ni, nj) = ([n.x, n.y, n.z][i], [n.x, n.y, n.z][j]);
|
||||
(if i == j { 1.0 } else { 0.0 }) - 2.0 * ni * nj
|
||||
};
|
||||
// p' = p - 2 (p·n - d) n
|
||||
[
|
||||
[m(0, 0), m(0, 1), m(0, 2), 0.0],
|
||||
[m(1, 0), m(1, 1), m(1, 2), 0.0],
|
||||
[m(2, 0), m(2, 1), m(2, 2), 0.0],
|
||||
[2.0 * d * n.x, 2.0 * d * n.y, 2.0 * d * n.z, 1.0],
|
||||
]
|
||||
}
|
||||
|
||||
const MAX_INSTANCES: usize = 1000;
|
||||
|
||||
fn pattern_transforms(vals: &BTreeMap<String, Value>, p: &crate::PatternKind) -> Result<Vec<Mat>> {
|
||||
let count = |e: &str| -> Result<usize> {
|
||||
let n = val(vals, e, Kind::Unitless)?.round();
|
||||
if !(1.0..=MAX_INSTANCES as f64).contains(&n) {
|
||||
return Err(DocError::Invalid(format!("pattern count must be 1…{MAX_INSTANCES}")));
|
||||
}
|
||||
Ok(n as usize)
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
match p {
|
||||
crate::PatternKind::Rectangular { dir1, count1, spacing1, dir2, count2, spacing2 } => {
|
||||
let (n1, s1) = (count(count1)?, val(vals, spacing1, Kind::Length)?);
|
||||
let d1 = dir1.normalized().ok_or_else(|| DocError::Invalid("pattern direction".into()))?;
|
||||
let (n2, s2, d2) = match (dir2, count2, spacing2) {
|
||||
(Some(d), Some(c), Some(sp)) => {
|
||||
(count(c)?, val(vals, sp, Kind::Length)?, d.normalized().ok_or_else(|| DocError::Invalid("pattern direction".into()))?)
|
||||
}
|
||||
_ => (1, 0.0, Vec3::Y),
|
||||
};
|
||||
if n1 * n2 > MAX_INSTANCES {
|
||||
return Err(DocError::Invalid("too many pattern instances".into()));
|
||||
}
|
||||
for i in 0..n1 {
|
||||
for j in 0..n2 {
|
||||
if i == 0 && j == 0 {
|
||||
continue;
|
||||
}
|
||||
out.push(translation(d1 * (s1 * i as f64) + d2 * (s2 * j as f64)));
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::PatternKind::Circular { origin, axis, count: c, angle } => {
|
||||
let n = count(c)?;
|
||||
let total = val(vals, angle, Kind::Angle)?;
|
||||
let full = (total.abs() - std::f64::consts::TAU).abs() < 1e-9;
|
||||
let step = if full {
|
||||
total / n as f64
|
||||
} else if n > 1 {
|
||||
total / (n - 1) as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
for i in 1..n {
|
||||
out.push(rotation(*origin, *axis, step * i as f64));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Apply transformed copies of the tools of `features` (patterns, mirrors).
|
||||
fn replay(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, features: &[String], mats: &[Mat]) -> Result<()> {
|
||||
if features.is_empty() {
|
||||
return Err(DocError::Invalid("no features selected".into()));
|
||||
}
|
||||
for name in features {
|
||||
let src = doc.find_feature(name).ok_or_else(|| DocError::Unknown(format!("feature `{name}`")))?;
|
||||
let tools = feature_tools(vals, src, st)?;
|
||||
let (op, targets) = feature_op(src);
|
||||
let mut copies = Vec::new();
|
||||
for m in mats {
|
||||
for t in &tools {
|
||||
copies.push(kernel::transform_matrix(t, *m)?);
|
||||
}
|
||||
}
|
||||
// New bodies from a pattern join their source when they touch it (as one feature would).
|
||||
apply_op(st, f, copies, op, &targets)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
|
||||
match &f.kind {
|
||||
FeatureKind::Sketch { plane, sketch } => {
|
||||
let plane = doc.resolve_plane(vals, plane, 0)?;
|
||||
let mut sk = sketch.clone();
|
||||
doc.apply_dimension_values(vals, &mut sk)?;
|
||||
let report = solve(&mut sk);
|
||||
if !report.ok() {
|
||||
*warning = Some(format!("the sketch constraints conflict ({})", report.failing.join(", ")));
|
||||
}
|
||||
let profiles = find_profiles(&sk);
|
||||
st.sketches.retain(|s| s.feature != f.id);
|
||||
st.sketches.push(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles });
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Extrude { operation, targets, .. } | FeatureKind::Revolve { operation, targets, .. } => {
|
||||
let tools = feature_tools(vals, f, st)?;
|
||||
apply_op(st, f, tools, *operation, targets)
|
||||
}
|
||||
FeatureKind::ConstructionPlane { plane } => {
|
||||
doc.resolve_plane(vals, plane, 0)?;
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Split { body, plane } => {
|
||||
let pl = doc.resolve_plane(vals, plane, 0)?;
|
||||
let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Unknown(format!("body `{body}`"))) };
|
||||
let parts = kernel::split_by_plane(&mb.body, &pl)?;
|
||||
if parts.len() < 2 {
|
||||
return Err(DocError::Invalid("the plane does not split the body".into()));
|
||||
}
|
||||
let mut it = parts.into_iter();
|
||||
if let (Some(first), Some(slot)) = (it.next(), st.bodies.get_mut(i)) {
|
||||
*slot = ModelBody::new(mb.name.clone(), first, mb.feature);
|
||||
}
|
||||
for (k, b) in it.enumerate() {
|
||||
let mut name = format!("{} ({})", mb.name, k + 1);
|
||||
while st.body(&name).is_some() {
|
||||
name.push('\'');
|
||||
}
|
||||
st.bodies.push(ModelBody::new(name, b, f.id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Pattern { features, pattern } => {
|
||||
let mats = pattern_transforms(vals, pattern)?;
|
||||
replay(doc, vals, f, st, features, &mats)
|
||||
}
|
||||
FeatureKind::Mirror { features, plane } => {
|
||||
let pl = doc.resolve_plane(vals, plane, 0)?;
|
||||
replay(doc, vals, f, st, features, &[mirror_matrix(&pl)])
|
||||
}
|
||||
FeatureKind::Fillet { edges, radius, body } | FeatureKind::Chamfer { edges, distance: radius, body } => {
|
||||
let r = val(vals, radius, Kind::Length)?;
|
||||
let ti = blend_target(st, body, edges)?;
|
||||
|
||||
@@ -29,6 +29,24 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?"),
|
||||
CommandSpec::new("FusionChamferCommand", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; body?"),
|
||||
CommandSpec::new("FusionCombineCommand", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool"),
|
||||
CommandSpec::new("PatternRectangular", "Rectangular Pattern", pattern_rect)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("pattern_rect")
|
||||
.params("features: [names]; dir1: [x,y,z]; count1; spacing1; dir2?, count2?, spacing2?"),
|
||||
CommandSpec::new("PatternCircular", "Circular Pattern", pattern_circ)
|
||||
.at("SOLID", "CREATE")
|
||||
.icon("pattern_circ")
|
||||
.params("features: [names]; axis: X|Y|Z | {origin, dir}; count; angle? (default 360 deg)"),
|
||||
CommandSpec::new("MirrorCommand", "Mirror", mirror).at("SOLID", "CREATE").icon("mirror").params("features: [names]; plane: XY|XZ|YZ | {origin, x_dir, y_dir}"),
|
||||
CommandSpec::new("ConstructionPlaneOffsetFromPlaneCommand", "Offset Plane", plane_offset)
|
||||
.at("SOLID", "CONSTRUCT")
|
||||
.icon("plane")
|
||||
.params("base: XY|XZ|YZ|plane name; offset: expr; name?"),
|
||||
CommandSpec::new("ConstructionPlaneAtAngleCommand", "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("FusionSplitBodyCommand", "Split Body", split_body).at("SOLID", "MODIFY").icon("split").params("body: name; plane: XY|XZ|YZ|plane name|{origin, normal}"),
|
||||
CommandSpec::new("FusionMoveCommand", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr"),
|
||||
];
|
||||
|
||||
@@ -329,3 +347,138 @@ fn move_bodies(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
add_feature(s, p, FeatureKind::Move { bodies, translate: t, rotate_axis: p.get("axis").and_then(vec3), angle })
|
||||
}
|
||||
|
||||
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 = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
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")),
|
||||
}
|
||||
}
|
||||
|
||||
fn pattern_rect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "PatternRectangular";
|
||||
let features = source_features(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 = solvecraft_doc::PatternKind::Rectangular { dir1, count1, spacing1, dir2, count2, spacing2 };
|
||||
add_feature(s, p, FeatureKind::Pattern { features, pattern })
|
||||
}
|
||||
|
||||
fn pattern_circ(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "PatternCircular";
|
||||
let features = source_features(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 = solvecraft_doc::PatternKind::Circular { origin, axis, count, angle };
|
||||
add_feature(s, p, FeatureKind::Pattern { features, pattern })
|
||||
}
|
||||
|
||||
fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "MirrorCommand";
|
||||
let features = source_features(s, p, cmd)?;
|
||||
let plane = match p.get("plane") {
|
||||
Some(Value::String(n)) if solvecraft_geom::Plane::named(n).is_some() => solvecraft_doc::PlaneRef::Origin { name: n.to_ascii_uppercase() },
|
||||
Some(o @ Value::Object(_)) => {
|
||||
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
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), _) => solvecraft_geom::Plane::new(origin, x, y),
|
||||
(_, _, Some(n)) => solvecraft_geom::Plane::from_normal(origin, n),
|
||||
_ => None,
|
||||
};
|
||||
solvecraft_doc::PlaneRef::Custom { plane: pl.ok_or_else(|| bad(cmd, "plane needs x_dir and y_dir, or normal"))? }
|
||||
}
|
||||
_ => return Err(bad(cmd, "`plane` must be XY, XZ, YZ or {origin, normal}")),
|
||||
};
|
||||
add_feature(s, p, FeatureKind::Mirror { features, plane })
|
||||
}
|
||||
|
||||
/// A plane reference from a parameter: an origin plane, a construction plane name, or an
|
||||
/// explicit plane `{origin, x_dir, y_dir}` / `{origin, normal}`.
|
||||
pub fn plane_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result<solvecraft_doc::PlaneRef> {
|
||||
use solvecraft_doc::PlaneRef;
|
||||
match v {
|
||||
Some(Value::String(n)) if solvecraft_geom::Plane::named(n).is_some() => Ok(PlaneRef::Origin { name: n.to_ascii_uppercase() }),
|
||||
Some(Value::String(n)) => match s.doc.find_feature(n).map(|f| &f.kind) {
|
||||
Some(FeatureKind::ConstructionPlane { .. }) => Ok(PlaneRef::Construction { name: n.clone() }),
|
||||
_ => Err(bad(cmd, format!("no plane `{n}`"))),
|
||||
},
|
||||
Some(o @ Value::Object(_)) => {
|
||||
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
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), _) => solvecraft_geom::Plane::new(origin, x, y),
|
||||
(_, _, Some(n)) => solvecraft_geom::Plane::from_normal(origin, n),
|
||||
_ => None,
|
||||
};
|
||||
Ok(PlaneRef::Custom { plane: pl.ok_or_else(|| bad(cmd, "plane needs x_dir and y_dir, or normal"))? })
|
||||
}
|
||||
_ => Err(bad(cmd, "`plane` must be XY, XZ, YZ, a construction plane name or {origin, normal}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn plane_offset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "ConstructionPlaneOffsetFromPlaneCommand";
|
||||
let base = plane_param(s, p.get("base"), cmd)?;
|
||||
let d = req_expr(cmd, p, "offset")?;
|
||||
check_expr(s, &d, Kind::Length, cmd, "offset")?;
|
||||
add_feature(s, p, FeatureKind::ConstructionPlane { plane: solvecraft_doc::PlaneRef::Offset { base: Box::new(base), distance: d } })
|
||||
}
|
||||
|
||||
fn plane_angle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "ConstructionPlaneAtAngleCommand";
|
||||
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: solvecraft_doc::PlaneRef::AtAngle { base: Box::new(base), axis_origin, axis_dir, angle } },
|
||||
)
|
||||
}
|
||||
|
||||
fn split_body(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "FusionSplitBodyCommand";
|
||||
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}`")));
|
||||
}
|
||||
let plane = plane_param(s, p.get("plane"), cmd)?;
|
||||
add_feature(s, p, FeatureKind::Split { body, plane })
|
||||
}
|
||||
@@ -286,10 +286,13 @@ fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result<PlaneRef> {
|
||||
let base = match p.get("plane") {
|
||||
None => PlaneRef::Origin { name: "XY".into() },
|
||||
Some(Value::String(n)) => {
|
||||
if Plane::named(n).is_none() {
|
||||
return Err(bad(cmd, format!("unknown plane `{n}` (XY, XZ or YZ)")));
|
||||
if Plane::named(n).is_some() {
|
||||
PlaneRef::Origin { name: n.to_ascii_uppercase() }
|
||||
} else if matches!(s.doc.find_feature(n).map(|f| &f.kind), Some(FeatureKind::ConstructionPlane { .. })) {
|
||||
PlaneRef::Construction { name: n.clone() }
|
||||
} else {
|
||||
return Err(bad(cmd, format!("unknown plane `{n}` (XY, XZ, YZ or a construction plane)")));
|
||||
}
|
||||
PlaneRef::Origin { name: n.to_ascii_uppercase() }
|
||||
}
|
||||
Some(v @ Value::Object(o)) => {
|
||||
if let Some(fp) = o.get("face").and_then(vec3) {
|
||||
|
||||
+117
-2
@@ -301,7 +301,122 @@ pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
|
||||
blend(body, edges, radius, Shape::Round, "fillet")
|
||||
}
|
||||
|
||||
/// Equal-distance chamfer of the edges nearest to the given points.
|
||||
/// Equal-distance chamfer of the edges nearest to the given points. Each edge is cut with a
|
||||
/// prism whose only face inside the body is the chamfer plane (so neighbouring chamfers meet in
|
||||
/// a mitre); the local operation is the fallback.
|
||||
pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result<Body> {
|
||||
blend(body, edges, distance, Shape::Flat, "chamfer")
|
||||
if !(distance.is_finite() && distance > 1e-6 && distance < 1e6) {
|
||||
return Err(KernelError::Invalid("chamfer size must be positive".into()));
|
||||
}
|
||||
if edges.is_empty() || edges.len() > 1000 {
|
||||
return Err(KernelError::Invalid("select 1…1000 edges".into()));
|
||||
}
|
||||
// All cutters at once (their union mitres the corners), else one edge at a time.
|
||||
let tools: Result<Vec<Body>> = edges.iter().enumerate().map(|(i, p)| chamfer_tool(body, *p, distance, i)).collect();
|
||||
if let Ok(tools) = tools {
|
||||
let mut acc: Option<Body> = None;
|
||||
let mut ok = true;
|
||||
for t in tools {
|
||||
let next = match &acc {
|
||||
None => Some(t),
|
||||
Some(a) => crate::ops::boolean(a, &t, crate::BoolOp::Union).ok().flatten(),
|
||||
};
|
||||
if next.is_none() {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
acc = next;
|
||||
}
|
||||
if ok
|
||||
&& let Some(tool) = acc
|
||||
&& let Ok(Some(r)) = crate::ops::boolean(body, &tool, crate::BoolOp::Cut)
|
||||
{
|
||||
return Ok(r);
|
||||
}
|
||||
}
|
||||
let mut cur = body.clone();
|
||||
for p in edges {
|
||||
cur = match chamfer_tool(&cur, *p, distance, 0).and_then(|t| crate::ops::boolean(&cur, &t, crate::BoolOp::Cut)) {
|
||||
Ok(Some(b)) => b,
|
||||
_ => blend(&cur, std::slice::from_ref(p), distance, Shape::Flat, "chamfer")?,
|
||||
};
|
||||
}
|
||||
Ok(cur)
|
||||
}
|
||||
|
||||
/// Geometry of a straight convex edge between two planar faces.
|
||||
struct EdgeFrame {
|
||||
p0: Vec3,
|
||||
p1: Vec3,
|
||||
d: Vec3,
|
||||
n1: Vec3,
|
||||
n2: Vec3,
|
||||
t1: Vec3,
|
||||
t2: Vec3,
|
||||
}
|
||||
|
||||
fn edge_frame(cur: &Body, p: Vec3) -> Result<EdgeFrame> {
|
||||
let size = cur.size();
|
||||
let tol = (size * 2e-3).max(1e-3);
|
||||
let Some((idx, dist)) = cur.nearest_edge(p, tol)? else { return Err(KernelError::Invalid("the body has no edges".into())) };
|
||||
if dist > size * 0.05 + 1e-3 {
|
||||
return Err(KernelError::Invalid(format!("no edge near {:?}", [p.x, p.y, p.z])));
|
||||
}
|
||||
let solid = &*cur.solid;
|
||||
let edge = Body::unique_edges(solid).into_iter().nth(idx).ok_or_else(|| KernelError::Invalid("edge index".into()))?;
|
||||
if !matches!(edge.curve(), mt::Curve::Line(_)) {
|
||||
return Err(unsupported("only straight edges"));
|
||||
}
|
||||
let faces: Vec<&mt::Face> = solid.face_iter().filter(|f| f.edge_iter().any(|e| e.id() == edge.id())).collect();
|
||||
let [f1, f2] = faces[..] else { return Err(unsupported("the edge is not between two faces")) };
|
||||
let (Some(n1), Some(n2)) = (plane_normal(f1), plane_normal(f2)) else { return Err(unsupported("both faces next to the edge must be planar")) };
|
||||
let (p0, p1) = (vtx(edge.absolute_front()), vtx(edge.absolute_back()));
|
||||
let d = (p1 - p0).normalized().ok_or_else(|| KernelError::Failed("zero-length edge".into()))?;
|
||||
let mut t1 = n1.cross(d);
|
||||
if t1.dot(n2) > 0.0 {
|
||||
t1 = -t1;
|
||||
}
|
||||
let mut t2 = n2.cross(d);
|
||||
if t2.dot(n1) > 0.0 {
|
||||
t2 = -t2;
|
||||
}
|
||||
if n1.dot(t2) > 1e-9 || n2.dot(t1) > 1e-9 || t1.dot(t2) < -1.0 + 1e-6 {
|
||||
return Err(unsupported("only convex edges"));
|
||||
}
|
||||
Ok(EdgeFrame { p0, p1, d, n1, n2, t1, t2 })
|
||||
}
|
||||
|
||||
/// The prism that cuts a chamfer of size `s` on the edge nearest `p`. `k` varies the parts that
|
||||
/// stay outside the body so that neighbouring cutters don't share edges.
|
||||
pub(crate) fn chamfer_tool(cur: &Body, p: Vec3, s: f64, k: usize) -> Result<Body> {
|
||||
let f = edge_frame(cur, p)?;
|
||||
let size = cur.size();
|
||||
let len = f.p0.dist(f.p1);
|
||||
let ext = (s * 2.0).max(size * 0.01) * (1.0 + 0.093 * (k % 5) as f64);
|
||||
// Cross-section at p0 - d*ext, in a plane with normal d.
|
||||
let base = f.p0 - f.d * ext;
|
||||
let plane = solvecraft_geom::Plane::from_normal(base, f.d).ok_or_else(|| KernelError::Failed("chamfer plane".into()))?;
|
||||
let a = base + f.t1 * s;
|
||||
let b = base + f.t2 * s;
|
||||
let ab = (a - b).normalized().ok_or_else(|| KernelError::Failed("chamfer".into()))?;
|
||||
let out = (f.n1 + f.n2).normalized().ok_or_else(|| KernelError::Failed("chamfer".into()))?;
|
||||
let vary = 1.0 + 0.137 * (k % 7) as f64;
|
||||
let reach = (s * 4.0 + size * 0.02) * vary;
|
||||
let a_ext = a + ab * (s * 0.5 + reach * 0.05);
|
||||
let b_ext = b - ab * (s * 0.5 + reach * 0.07);
|
||||
let e_far = base + out * reach;
|
||||
// The material beyond both ends must be empty (the cutter runs past them).
|
||||
let mid = (a + b + base) / 3.0;
|
||||
for probe in [mid + f.d * (ext * 0.5), mid + f.d * (len + ext * 1.5)] {
|
||||
if cur.contains(probe) {
|
||||
return Err(unsupported("chamfers ending at a concave corner"));
|
||||
}
|
||||
}
|
||||
let loc: Vec<solvecraft_geom::Vec2> = [a_ext, b_ext, e_far].iter().map(|q| plane.to_local(*q)).collect();
|
||||
let mut lp = solvecraft_geom::Loop2::polygon(&loc);
|
||||
if lp.signed_area() < 0.0 {
|
||||
lp = lp.reversed();
|
||||
}
|
||||
let region = solvecraft_geom::Region2 { outer: lp, holes: vec![] };
|
||||
crate::build::extrude(&plane, &[region], 0.0, len + 2.0 * ext)?.pop().ok_or_else(|| KernelError::Failed("chamfer tool".into()))
|
||||
}
|
||||
@@ -24,7 +24,7 @@ pub use blend::{chamfer, fillet};
|
||||
pub use body::{Body, EdgeInfo, FaceInfo};
|
||||
pub use build::{box_solid, cylinder, extrude, extrude_tapered, revolve, sphere, torus};
|
||||
pub use measure::{BodyMeasure, measure};
|
||||
pub use ops::{BoolOp, boolean, transform};
|
||||
pub use ops::{BoolOp, boolean, split_by_plane, transform, transform_matrix};
|
||||
pub use step::step_export;
|
||||
pub use topo::{TopoCounts, merged_topology, seam_flags};
|
||||
|
||||
|
||||
@@ -84,6 +84,20 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
|
||||
Err(KernelError::Failed(format!("boolean {op:?}: {last}")))
|
||||
}
|
||||
|
||||
/// Affine transform by a column-major 4×4 matrix (rotations, translations, reflections).
|
||||
pub fn transform_matrix(body: &Body, m: [[f64; 4]; 4]) -> Result<Body> {
|
||||
if m.iter().flatten().any(|x| !x.is_finite()) {
|
||||
return Err(KernelError::Invalid("non-finite transform".into()));
|
||||
}
|
||||
guard("transform", || {
|
||||
let mat = mt::Matrix4::new(
|
||||
m[0][0], m[0][1], m[0][2], m[0][3], m[1][0], m[1][1], m[1][2], m[1][3], m[2][0], m[2][1], m[2][2], m[2][3], m[3][0], m[3][1], m[3][2],
|
||||
m[3][3],
|
||||
);
|
||||
Body::new(mt::builder::transformed(&body.deep_copy(), mat))
|
||||
})
|
||||
}
|
||||
|
||||
/// Rigid transform: rotate about `axis` through `origin` by `angle` radians, then translate.
|
||||
pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle: f64) -> Result<Body> {
|
||||
if !(translate.is_finite() && origin.is_finite() && angle.is_finite()) {
|
||||
@@ -101,3 +115,32 @@ pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle:
|
||||
Body::new(s)
|
||||
})
|
||||
}
|
||||
|
||||
/// Split a body with a plane: the parts on the positive and the negative side of the plane
|
||||
/// (only the non-empty ones).
|
||||
pub fn split_by_plane(body: &Body, plane: &solvecraft_geom::Plane) -> Result<Vec<Body>> {
|
||||
let size = body.size();
|
||||
let mut bb = solvecraft_geom::Aabb3::EMPTY;
|
||||
for v in body.solid.vertex_iter() {
|
||||
bb.add(crate::body::from_p3(v.point()));
|
||||
}
|
||||
let reach = (bb.diagonal() + plane.origin.dist(bb.center())) * 2.0 + size;
|
||||
let c = plane.to_local(bb.center());
|
||||
let sq = solvecraft_geom::Region2 {
|
||||
outer: solvecraft_geom::Loop2::polygon(&[
|
||||
solvecraft_geom::Vec2::new(c.x - reach, c.y - reach),
|
||||
solvecraft_geom::Vec2::new(c.x + reach, c.y - reach),
|
||||
solvecraft_geom::Vec2::new(c.x + reach, c.y + reach),
|
||||
solvecraft_geom::Vec2::new(c.x - reach, c.y + reach),
|
||||
]),
|
||||
holes: vec![],
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for (lo, hi) in [(0.0, reach), (-reach, 0.0)] {
|
||||
let half = crate::build::extrude(plane, std::slice::from_ref(&sq), lo, hi)?.pop().ok_or_else(|| KernelError::Failed("half space".into()))?;
|
||||
if let Some(part) = boolean(body, &half, BoolOp::Intersect)? {
|
||||
out.push(part);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -264,3 +264,34 @@ fn debug_coplanar_union_rate() {
|
||||
}
|
||||
println!("ok {ok}/60");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chamfer_top_loop_mitres() {
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
|
||||
let edges = [Vec3::new(0.0, 15.0, 20.0), Vec3::new(20.0, 0.0, 20.0), Vec3::new(40.0, 15.0, 20.0), Vec3::new(20.0, 30.0, 20.0)];
|
||||
let c = chamfer(&b, &edges, 2.0).unwrap();
|
||||
let m = measure(&c).unwrap();
|
||||
assert!(rel(m.volume, 23730.666667) < 1e-6, "{}", m.volume);
|
||||
assert_eq!(m.merged.faces, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn debug_chamfer_cut() {
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap();
|
||||
// Prism along x: triangle in the YZ plane beyond the top-front edge.
|
||||
let plane = Plane { origin: Vec3::new(-5.0, 0.0, 0.0), x: Vec3::Y, y: Vec3::Z };
|
||||
let tri = Loop2::polygon(&[Vec2::new(2.3, 20.3), Vec2::new(-0.3, 17.7), Vec2::new(10.0, 30.0)]);
|
||||
let tri = if tri.signed_area() < 0.0 { tri.reversed() } else { tri };
|
||||
let tool = extrude(&plane, &[Region2 { outer: tri, holes: vec![] }], 0.0, 50.0).unwrap().pop().unwrap();
|
||||
println!("tool vol {}", measure(&tool).unwrap().volume);
|
||||
let r = boolean(&b, &tool, BoolOp::Cut).unwrap().unwrap();
|
||||
let m = measure(&r).unwrap();
|
||||
println!("cut: {} faces {} {:?}", m.volume, m.faces, m.merged);
|
||||
for f in r.faces(0.01).unwrap() {
|
||||
println!("face {} area {:.3} c {:?} n {:?}", f.index, f.area, f.centroid, f.plane_normal);
|
||||
}
|
||||
for tol in [0.1, 0.01, 0.001] {
|
||||
println!("tess {tol}: {}", r.tessellate(tol).unwrap().measure().volume);
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,36 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
pen.poly(&[(3.0, 8.0), (14.0, 8.0), (14.0, 19.0), (3.0, 19.0)], pen.fill, ink);
|
||||
pen.poly(&[(9.0, 4.0), (21.0, 4.0), (21.0, 15.0), (9.0, 15.0)], pen.fill.gamma_multiply(0.75), a);
|
||||
}
|
||||
"pattern_rect" => {
|
||||
for (x, y) in [(4.0, 4.0), (14.0, 4.0), (4.0, 14.0), (14.0, 14.0)] {
|
||||
pen.poly(
|
||||
&[(x, y), (x + 6.0, y), (x + 6.0, y + 6.0), (x, y + 6.0)],
|
||||
if x == 4.0 && y == 4.0 { pen.fill } else { Color32::TRANSPARENT },
|
||||
if x == 4.0 && y == 4.0 { ink } else { a },
|
||||
);
|
||||
}
|
||||
}
|
||||
"pattern_circ" => {
|
||||
for i in 0..6 {
|
||||
let t = i as f32 / 6.0 * std::f32::consts::TAU;
|
||||
pen.circle(
|
||||
12.0 + 7.5 * t.cos(),
|
||||
12.0 + 7.5 * t.sin(),
|
||||
2.4,
|
||||
if i == 0 { pen.fill } else { Color32::TRANSPARENT },
|
||||
if i == 0 { ink } else { a },
|
||||
);
|
||||
}
|
||||
}
|
||||
"split" => {
|
||||
pen.poly(&[(3.0, 6.0), (21.0, 6.0), (21.0, 18.0), (3.0, 18.0)], pen.fill, ink);
|
||||
pen.line(&[(8.0, 2.0), (16.0, 22.0)], a);
|
||||
}
|
||||
"mirror" => {
|
||||
pen.line(&[(12.0, 2.0), (12.0, 22.0)], ink);
|
||||
pen.poly(&[(3.0, 7.0), (9.0, 10.0), (9.0, 17.0), (3.0, 17.0)], pen.fill, ink);
|
||||
pen.poly(&[(21.0, 7.0), (15.0, 10.0), (15.0, 17.0), (21.0, 17.0)], Color32::TRANSPARENT, a);
|
||||
}
|
||||
"move" => {
|
||||
for (dx, dy) in [(0.0, -1.0), (0.0, 1.0), (-1.0, 0.0), (1.0, 0.0)] {
|
||||
pen.arrow((12.0, 12.0), (12.0 + 9.0 * dx, 12.0 + 9.0 * dy), a);
|
||||
|
||||
@@ -19,6 +19,11 @@ fn icon_of(k: &FeatureKind) -> &'static str {
|
||||
FeatureKind::Sphere { .. } => "sphere",
|
||||
FeatureKind::Torus { .. } => "torus",
|
||||
FeatureKind::Combine { .. } => "combine",
|
||||
FeatureKind::Pattern { pattern: solvecraft_engine::doc::PatternKind::Rectangular { .. }, .. } => "pattern_rect",
|
||||
FeatureKind::Pattern { .. } => "pattern_circ",
|
||||
FeatureKind::Mirror { .. } => "mirror",
|
||||
FeatureKind::ConstructionPlane { .. } => "plane",
|
||||
FeatureKind::Split { .. } => "split",
|
||||
FeatureKind::Move { .. } => "move",
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user