Surface Extend: a planar surface's straight edges pushed out (FusionExtendCommand)
Kernel extend(body, edges, distance) moves the picked straight edges of a planar surface outward in its plane, with neighbours meeting them. Doc feature SurfaceExtend, engine FusionExtendCommand. Tests: 10x10 extended by 5 past one edge gives 150, past two gives 225; the engine patch, trim, extend and thicken gives 240. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
dd6c8e1a6b
commit
b8d00aea13
9 files changed
+130
-9
No files matched your search
@@ -261,7 +261,7 @@ impl FeatureKind {
|
||||
| FeatureKind::Thicken { .. }
|
||||
| FeatureKind::Import { .. }
|
||||
| FeatureKind::MeshImport { .. } => {}
|
||||
FeatureKind::Patch { edges, .. } => edges.iter_mut().for_each(pt),
|
||||
FeatureKind::Patch { edges, .. } | FeatureKind::SurfaceExtend { edges, .. } => edges.iter_mut().for_each(pt),
|
||||
FeatureKind::SurfaceTrim { plane, tool, keep, .. } => {
|
||||
map_plane_ref(m, plane);
|
||||
if let Some(t) = tool {
|
||||
|
||||
@@ -634,6 +634,12 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
targets: Vec<String>,
|
||||
},
|
||||
/// A planar surface body extended outward by `distance` past the edges through `edges`.
|
||||
SurfaceExtend {
|
||||
body: String,
|
||||
edges: Vec<Vec3>,
|
||||
distance: String,
|
||||
},
|
||||
/// A surface body trimmed by a plane, or by a face of a body (`tool`), keeping the side of
|
||||
/// `keep`.
|
||||
SurfaceTrim {
|
||||
@@ -887,6 +893,7 @@ impl FeatureKind {
|
||||
FeatureKind::Stitch { .. } => "StitchFeature",
|
||||
FeatureKind::Thicken { .. } => "ThickenFeature",
|
||||
FeatureKind::SurfaceTrim { .. } => "TrimFeature",
|
||||
FeatureKind::SurfaceExtend { .. } => "ExtendFeature",
|
||||
FeatureKind::Import { .. } => "BaseFeature",
|
||||
FeatureKind::MeshImport { .. } => "MeshFeature",
|
||||
}
|
||||
@@ -945,6 +952,7 @@ impl FeatureKind {
|
||||
FeatureKind::Stitch { .. } => "Stitch",
|
||||
FeatureKind::Thicken { .. } => "Thicken",
|
||||
FeatureKind::SurfaceTrim { .. } => "Trim",
|
||||
FeatureKind::SurfaceExtend { .. } => "Extend",
|
||||
FeatureKind::Import { .. } => "Import",
|
||||
FeatureKind::MeshImport { .. } => "Mesh",
|
||||
}
|
||||
@@ -1086,6 +1094,7 @@ impl FeatureKind {
|
||||
FeatureKind::Stitch { tolerance, .. } => v.push(tolerance),
|
||||
FeatureKind::Thicken { thickness, .. } => v.push(thickness),
|
||||
FeatureKind::SurfaceTrim { plane, .. } => plane_exprs(plane, &mut v),
|
||||
FeatureKind::SurfaceExtend { distance, .. } => v.push(distance),
|
||||
FeatureKind::Patch { .. } => {}
|
||||
FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } => {}
|
||||
}
|
||||
|
||||
@@ -1916,9 +1916,11 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
FeatureKind::Boss { .. } | FeatureKind::Lip { .. } | FeatureKind::SnapFit { .. } | FeatureKind::Rest { .. } => {
|
||||
more::plastic_eval(doc, vals, f, st)
|
||||
}
|
||||
FeatureKind::Patch { .. } | FeatureKind::Stitch { .. } | FeatureKind::Thicken { .. } | FeatureKind::SurfaceTrim { .. } => {
|
||||
surface::eval(doc, vals, f, st)
|
||||
}
|
||||
FeatureKind::Patch { .. }
|
||||
| FeatureKind::Stitch { .. }
|
||||
| FeatureKind::Thicken { .. }
|
||||
| FeatureKind::SurfaceTrim { .. }
|
||||
| FeatureKind::SurfaceExtend { .. } => surface::eval(doc, vals, f, st),
|
||||
FeatureKind::Emboss { .. }
|
||||
| FeatureKind::Rib { .. }
|
||||
| FeatureKind::ReplaceFace { .. }
|
||||
|
||||
@@ -74,6 +74,16 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::SurfaceExtend { body, edges, distance } => {
|
||||
let d = val(vals, distance, Kind::Length)?;
|
||||
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::Invalid("body".into())) };
|
||||
let out = kernel::extend(&mb.body, edges, d)?;
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::new(mb.name, out, mb.feature);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(DocError::Invalid("not a surface feature".into())),
|
||||
}
|
||||
}
|
||||
@@ -226,6 +226,7 @@ impl FeatureKind {
|
||||
FeatureKind::Stitch { tolerance, .. } => v.push(("Tolerance", tolerance, L)),
|
||||
FeatureKind::Thicken { thickness, .. } => v.push(("Thickness", thickness, L)),
|
||||
FeatureKind::SurfaceTrim { plane, .. } => plane_inputs_mut(plane, &mut v),
|
||||
FeatureKind::SurfaceExtend { distance, .. } => v.push(("Distance", distance, L)),
|
||||
FeatureKind::Patch { .. } => {}
|
||||
FeatureKind::Combine { .. }
|
||||
| FeatureKind::SheetBase { .. }
|
||||
|
||||
@@ -22,6 +22,10 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("thicken")
|
||||
.params("bodies: [surface names]; thickness: expr (negative: against the normal); symmetric?: bool; operation?: new|join|cut|intersect; targets?"),
|
||||
CommandSpec::new("FusionExtendCommand", "Extend", extend)
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("extend")
|
||||
.params("body: planar surface name; edges: [[x,y,z]] (points on straight edges to push out); distance: expr"),
|
||||
CommandSpec::new("FusionSurfaceTrimCommand", "Trim", trim)
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("trim")
|
||||
@@ -78,6 +82,21 @@ fn thicken(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add_feature(s, p, kind)
|
||||
}
|
||||
|
||||
fn extend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "FusionExtendCommand";
|
||||
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 edges: Vec<solvecraft_geom::Vec3> = p.get("edges").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default();
|
||||
if edges.is_empty() || edges.len() > 10_000 {
|
||||
return Err(bad(cmd, "`edges` must list points on the edges to extend"));
|
||||
}
|
||||
let distance = expr(p, "distance").ok_or_else(|| bad(cmd, "`distance` is required"))?;
|
||||
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
|
||||
add_feature(s, p, FeatureKind::SurfaceExtend { body, edges, distance })
|
||||
}
|
||||
|
||||
fn trim(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "FusionSurfaceTrimCommand";
|
||||
let body = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?.to_string();
|
||||
@@ -121,9 +140,10 @@ mod tests {
|
||||
let st = s.world_state();
|
||||
assert!(st.body("Sheet").unwrap().body.is_surface());
|
||||
run(&mut s, "FusionSurfaceTrimCommand", json!({"body": "Sheet", "plane": {"origin": [3, 0, 0], "normal": [1, 0, 0]}, "keep": [8, 5, 0]}));
|
||||
run(&mut s, "FusionExtendCommand", json!({"body": "Sheet", "edges": [[10, 5, 0]], "distance": 5}));
|
||||
run(&mut s, "FusionSurfaceThickenCommand", json!({"bodies": ["Sheet"], "thickness": "2 mm", "body_name": "Slab"}));
|
||||
let v = volume(&s, "Slab");
|
||||
assert!((v - 140.0).abs() < 1e-9, "{v}");
|
||||
assert!((v - 240.0).abs() < 1e-9, "{v}");
|
||||
// Stitch the patch of a box's open top back on.
|
||||
run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Cup"}));
|
||||
run(
|
||||
|
||||
@@ -57,7 +57,7 @@ pub use splitface::{SplitTool, split_body, split_faces};
|
||||
pub use step::step_export;
|
||||
pub use step_in::{ImportedBody, StepImport, StepNode, step_import, step_import_shared, step_orientation_errors, step_validate};
|
||||
pub use step_out::{ExportBody, ExportProduct, StepHeader, step_export_bodies, step_export_products};
|
||||
pub use surfaces::{copy_faces, patch_edges, patch_region, stitch, thicken, trim};
|
||||
pub use surfaces::{copy_faces, extend, patch_edges, patch_region, stitch, thicken, trim};
|
||||
pub use sweep_path::sweep_path;
|
||||
pub use topo::{CylinderFace, cylinder_face_at};
|
||||
pub use topo::{TopoCounts, merged_topology, seam_flags};
|
||||
|
||||
@@ -480,6 +480,71 @@ pub fn trim(body: &Body, tool: &crate::SplitTool, keep: Vec3) -> Result<Body> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A planar surface extended: the chosen straight edges (picked by points on them) move
|
||||
/// outward in the face's plane by `distance`, their neighbours lengthened to meet them.
|
||||
pub fn extend(body: &Body, at: &[Vec3], distance: f64) -> Result<Body> {
|
||||
body.require_brep("extend")?;
|
||||
if !(distance.is_finite() && distance > 0.0) {
|
||||
return Err(KernelError::Invalid("the extension distance must be positive".into()));
|
||||
}
|
||||
let faces: Vec<mt::Face> = body.solid.face_iter().cloned().collect();
|
||||
let [face] = faces.as_slice() else { return Err(KernelError::Invalid("not supported yet: extending a surface of more than one face".into())) };
|
||||
let Some(crate::splitface::Analytic::Plane { n }) = crate::splitface::analytic_field(&face.oriented_surface()) else {
|
||||
return Err(KernelError::Invalid("not supported yet: extending a surface that is not planar".into()));
|
||||
};
|
||||
let chosen: Vec<mt::EdgeID> = at
|
||||
.iter()
|
||||
.map(|p| edge_near(body, *p).map(|e| e.id()).ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}"))))
|
||||
.collect::<Result<_>>()?;
|
||||
guard("extend", || {
|
||||
let bounds = face.boundaries();
|
||||
let [outer] = bounds.as_slice() else { return Err(KernelError::Invalid("not supported yet: extending a surface with holes".into())) };
|
||||
let edges: Vec<mt::Edge> = outer.edge_iter().cloned().collect();
|
||||
let m = edges.len();
|
||||
// Each edge as a line (point, direction) moved outward when chosen.
|
||||
let mut lines = Vec::with_capacity(m);
|
||||
for e in &edges {
|
||||
let (a, b) = (from_p3(e.front().point()), from_p3(e.back().point()));
|
||||
if !matches!(e.curve(), mt::Curve::Line(_)) {
|
||||
return Err(KernelError::Invalid("not supported yet: extending a surface with curved edges".into()));
|
||||
}
|
||||
let d = (b - a).normalized().ok_or_else(|| KernelError::Invalid("a zero-length edge".into()))?;
|
||||
// Outward: right of the edge seen along the normal (the face is on its left).
|
||||
let out = d.cross(n);
|
||||
let shift = if chosen.contains(&e.id()) || chosen.contains(&e.inverse().id()) { distance } else { 0.0 };
|
||||
lines.push((a + out * shift, d));
|
||||
}
|
||||
// Corners: where consecutive lines meet.
|
||||
let meet = |(p, d): (Vec3, Vec3), (q, e): (Vec3, Vec3)| -> Option<Vec3> {
|
||||
let w = q - p;
|
||||
let c = d.cross(e);
|
||||
let cc = c.dot(c);
|
||||
if cc < 1e-24 {
|
||||
return None;
|
||||
}
|
||||
Some(p + d * (w.cross(e).dot(c) / cc))
|
||||
};
|
||||
let mut corners = Vec::with_capacity(m);
|
||||
for i in 0..m {
|
||||
let (Some(prev), Some(cur)) = (lines.get((i + m - 1) % m), lines.get(i)) else { continue };
|
||||
let p = meet(*prev, *cur).unwrap_or(cur.0);
|
||||
corners.push(mt::Vertex::new(p3(p)));
|
||||
}
|
||||
let wire: mt::Wire = (0..m)
|
||||
.filter_map(|i| {
|
||||
let (a, b) = (corners.get(i)?, corners.get((i + 1) % m)?);
|
||||
Some(mt::Edge::new_unchecked(a, b, mt::Curve::Line(mt::Line(a.point(), b.point()))))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into();
|
||||
let mut f = mt::builder::try_attach_plane(&[wire]).map_err(|e| KernelError::Failed(format!("extend: {e}")))?;
|
||||
if f.oriented_surface().normal(0.0, 0.0).dot(crate::body::v3(n)) < 0.0 {
|
||||
f = f.inverse();
|
||||
}
|
||||
Ok(surface_body(vec![f]))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -554,6 +619,20 @@ mod tests {
|
||||
assert!(rel(measure(&closed).unwrap().volume, 6000.0) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extend_a_planar_surface() {
|
||||
let r = Region2 {
|
||||
outer: Loop2::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(10.0, 10.0), Vec2::new(0.0, 10.0)]),
|
||||
holes: vec![],
|
||||
};
|
||||
let s = patch_region(&Plane::XY, &r).unwrap();
|
||||
let e = extend(&s, &[Vec3::new(10.0, 5.0, 0.0)], 5.0).unwrap();
|
||||
assert!(rel(measure(&e).unwrap().area, 150.0) < 1e-9);
|
||||
let e = extend(&s, &[Vec3::new(10.0, 5.0, 0.0), Vec3::new(5.0, 10.0, 0.0)], 5.0).unwrap();
|
||||
assert!(rel(measure(&e).unwrap().area, 225.0) < 1e-9);
|
||||
assert!(extend(&s, &[Vec3::new(10.0, 5.0, 0.0)], -1.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_keeps_one_side() {
|
||||
let r = Region2 {
|
||||
|
||||
+3
-3
@@ -2,9 +2,9 @@
|
||||
|
||||
Generated by `cargo xtask parity` from `xtask/data/fusion-catalog.tsv` (the toolbar commands of Fusion's Design workspace, ids and names only) and the live command registry (`solvecraft-cli commands`). A command counts as live when SolveCraft registers the same command id. This measures breadth, not depth; see ROADMAP.md and docs/oracle.md for depth.
|
||||
|
||||
**In scope (SOLID, SKETCH, ASSEMBLY, SHEET METAL, PLASTIC enclosure subset): 279 / 541 commands live (52%).** SOLID + SKETCH: 168 / 291 (58%). Registered commands: 280.
|
||||
**In scope (SOLID, SKETCH, ASSEMBLY, SHEET METAL, PLASTIC enclosure subset): 279 / 541 commands live (52%).** SOLID + SKETCH: 168 / 291 (58%). Registered commands: 281.
|
||||
|
||||
Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE beyond what solids need, PCB, render, animation, simulation, manufacture, drawings, MANAGE, UTILITIES and the rest of PLASTIC. All Design tabs together: 500 / 1175 (43%).
|
||||
Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE beyond what solids need, PCB, render, animation, simulation, manufacture, drawings, MANAGE, UTILITIES and the rest of PLASTIC. All Design tabs together: 501 / 1175 (43%).
|
||||
|
||||
| Tab | Panel | Live | Total | % |
|
||||
|---|---|---:|---:|---:|
|
||||
@@ -54,7 +54,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE
|
||||
| Tab | Panel | Live | Total | % |
|
||||
|---|---|---:|---:|---:|
|
||||
| SURFACE | CREATE | 9 | 25 | 36% |
|
||||
| SURFACE | MODIFY | 15 | 26 | 58% |
|
||||
| SURFACE | MODIFY | 16 | 26 | 62% |
|
||||
| SURFACE | ASSEMBLE | 11 | 20 | 55% |
|
||||
| SURFACE | CONFIGURE | 2 | 3 | 67% |
|
||||
| SURFACE | CONSTRUCT | 2 | 20 | 10% |
|
||||
|
||||
Reference in new issue
Block a user