Surface bodies: Patch, Stitch, Trim and Thicken
Kernel (surfaces): - Bodies with an open shell are surface bodies (Body::is_surface). - patch_region: a planar patch of a sketch region. patch_edges: a planar patch filling a closed loop of a body's edges. - copy_faces: faces copied out as a surface. - stitch: sew surfaces along coinciding edges, turning faces to agree; the result is a solid when it closes. - trim: cut a surface by a plane or face and keep one side. - thicken: offset planar and circular-cylindrical faces along their normals (one side or symmetric) into a solid. Plane copies slide; cylinder copies scale about the axis, exact for NURBS circles. Free edges get ruled walls. Faces meeting at a sharp edge are refused. Doc features Patch, Stitch, Thicken (with operations) and SurfaceTrim; engine commands FusionSurfacePatchCommand, FusionSurfaceStitchCommand, FusionSurfaceThickenCommand and FusionSurfaceTrimCommand. Tests (volumes): - A 10x10 patch thickened by 2 gives 200, also symmetric. - Half a cylinder side thickened outward gives half a ring; both halves inward give the full ring. - Six faces of a box stitched back give 6000; an open box with its top patched from the edges, stitched, gives 6000. - Trim keeps 30 or 70 of 100. - Engine: patch, trim, thicken gives 140. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
feaaba156e
commit
dd6c8e1a6b
11 files changed
+908
-11
No files matched your search
@@ -153,10 +153,15 @@ impl FeatureKind {
|
||||
let pt = |p: &mut Vec3| *p = apply_point(m, *p);
|
||||
let dir = |v: &mut Vec3| *v = apply_vector(m, *v);
|
||||
match self {
|
||||
FeatureKind::Sketch { plane, .. }
|
||||
| FeatureKind::ConstructionPlane { plane }
|
||||
| FeatureKind::Split { plane, .. }
|
||||
| FeatureKind::Mirror { plane, .. } => map_plane_ref(m, plane),
|
||||
FeatureKind::Sketch { plane, .. } | FeatureKind::ConstructionPlane { plane } | FeatureKind::Mirror { plane, .. } => {
|
||||
map_plane_ref(m, plane)
|
||||
}
|
||||
FeatureKind::Split { plane, tool, .. } => {
|
||||
map_plane_ref(m, plane);
|
||||
if let Some(t) = tool {
|
||||
pt(&mut t.point);
|
||||
}
|
||||
}
|
||||
FeatureKind::Revolve { axis: AxisRef::Line { origin, dir: d }, .. } => {
|
||||
pt(origin);
|
||||
dir(d);
|
||||
@@ -252,8 +257,18 @@ impl FeatureKind {
|
||||
| FeatureKind::Emboss { .. }
|
||||
| FeatureKind::Rib { .. }
|
||||
| FeatureKind::Remove { .. }
|
||||
| FeatureKind::Stitch { .. }
|
||||
| FeatureKind::Thicken { .. }
|
||||
| FeatureKind::Import { .. }
|
||||
| FeatureKind::MeshImport { .. } => {}
|
||||
FeatureKind::Patch { edges, .. } => edges.iter_mut().for_each(pt),
|
||||
FeatureKind::SurfaceTrim { plane, tool, keep, .. } => {
|
||||
map_plane_ref(m, plane);
|
||||
if let Some(t) = tool {
|
||||
pt(&mut t.point);
|
||||
}
|
||||
pt(keep);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,6 +605,44 @@ pub enum FeatureKind {
|
||||
Remove {
|
||||
bodies: Vec<String>,
|
||||
},
|
||||
/// Surface bodies: planar patches filling sketch regions (`profiles` of `sketch`), or a
|
||||
/// closed loop of a body's edges (`edges`: points on them, on `body`).
|
||||
Patch {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
sketch: Option<u64>,
|
||||
#[serde(default = "all_profiles")]
|
||||
profiles: ProfileSel,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
body: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
edges: Vec<Vec3>,
|
||||
},
|
||||
/// Surface bodies sewn along shared edges into the first one (a solid when they close).
|
||||
Stitch {
|
||||
bodies: Vec<String>,
|
||||
#[serde(default = "zero_expr")]
|
||||
tolerance: String,
|
||||
},
|
||||
/// Surface bodies thickened into solids along their normals (by half each way when
|
||||
/// `symmetric`).
|
||||
Thicken {
|
||||
bodies: Vec<String>,
|
||||
thickness: String,
|
||||
#[serde(default)]
|
||||
symmetric: bool,
|
||||
operation: Operation,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
targets: Vec<String>,
|
||||
},
|
||||
/// A surface body trimmed by a plane, or by a face of a body (`tool`), keeping the side of
|
||||
/// `keep`.
|
||||
SurfaceTrim {
|
||||
body: String,
|
||||
plane: PlaneRef,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
tool: Option<FaceAt>,
|
||||
keep: Vec3,
|
||||
},
|
||||
Move {
|
||||
bodies: Vec<String>,
|
||||
translate: [String; 3],
|
||||
@@ -845,6 +883,10 @@ impl FeatureKind {
|
||||
FeatureKind::ReplaceFace { .. } => "ReplaceFaceFeature",
|
||||
FeatureKind::Align { .. } => "AlignFeature",
|
||||
FeatureKind::Remove { .. } => "RemoveFeature",
|
||||
FeatureKind::Patch { .. } => "PatchFeature",
|
||||
FeatureKind::Stitch { .. } => "StitchFeature",
|
||||
FeatureKind::Thicken { .. } => "ThickenFeature",
|
||||
FeatureKind::SurfaceTrim { .. } => "TrimFeature",
|
||||
FeatureKind::Import { .. } => "BaseFeature",
|
||||
FeatureKind::MeshImport { .. } => "MeshFeature",
|
||||
}
|
||||
@@ -899,6 +941,10 @@ impl FeatureKind {
|
||||
FeatureKind::ReplaceFace { .. } => "ReplaceFace",
|
||||
FeatureKind::Align { .. } => "Align",
|
||||
FeatureKind::Remove { .. } => "Remove",
|
||||
FeatureKind::Patch { .. } => "Patch",
|
||||
FeatureKind::Stitch { .. } => "Stitch",
|
||||
FeatureKind::Thicken { .. } => "Thicken",
|
||||
FeatureKind::SurfaceTrim { .. } => "Trim",
|
||||
FeatureKind::Import { .. } => "Import",
|
||||
FeatureKind::MeshImport { .. } => "Mesh",
|
||||
}
|
||||
@@ -1037,6 +1083,10 @@ impl FeatureKind {
|
||||
}
|
||||
FeatureKind::ReplaceFace { target, .. } => plane_exprs(target, &mut v),
|
||||
FeatureKind::Align { .. } | FeatureKind::Remove { .. } => {}
|
||||
FeatureKind::Stitch { tolerance, .. } => v.push(tolerance),
|
||||
FeatureKind::Thicken { thickness, .. } => v.push(thickness),
|
||||
FeatureKind::SurfaceTrim { plane, .. } => plane_exprs(plane, &mut v),
|
||||
FeatureKind::Patch { .. } => {}
|
||||
FeatureKind::Import { .. } | FeatureKind::MeshImport { .. } => {}
|
||||
}
|
||||
v
|
||||
@@ -1080,6 +1130,10 @@ fn inside_str() -> String {
|
||||
"inside".into()
|
||||
}
|
||||
|
||||
fn all_profiles() -> ProfileSel {
|
||||
ProfileSel::All
|
||||
}
|
||||
|
||||
fn zero_expr() -> String {
|
||||
"0".into()
|
||||
}
|
||||
|
||||
@@ -1815,6 +1815,8 @@ fn replay(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut
|
||||
|
||||
#[path = "eval_more.rs"]
|
||||
mod more;
|
||||
#[path = "eval_surface.rs"]
|
||||
mod surface;
|
||||
|
||||
/// The plane of the body face a sketch sits on, found again: among planar faces with the
|
||||
/// picked plane's normal, the one nearest `at`. The picked frame moves along its normal onto it.
|
||||
@@ -1914,6 +1916,9 @@ 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::Emboss { .. }
|
||||
| FeatureKind::Rib { .. }
|
||||
| FeatureKind::ReplaceFace { .. }
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Evaluation of the surface features: Patch, Stitch, Thicken and Trim (kernel `surfaces`).
|
||||
|
||||
use super::*;
|
||||
|
||||
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState) -> Result<()> {
|
||||
match &f.kind {
|
||||
FeatureKind::Patch { sketch, profiles, body, edges } => {
|
||||
let mut made = Vec::new();
|
||||
if let Some(sk) = sketch {
|
||||
let ss = st.sketch(*sk).ok_or_else(|| DocError::Unknown(format!("sketch {sk} (it must come earlier in the timeline)")))?;
|
||||
let regions = solvecraft_sketch::merge_regions(&select_profiles(ss, profiles)?);
|
||||
for r in ®ions {
|
||||
made.push(kernel::patch_region(&ss.plane, r)?);
|
||||
}
|
||||
}
|
||||
if !edges.is_empty() {
|
||||
let i = body_at(st, body, edges)?;
|
||||
let b = st.bodies.get(i).ok_or_else(|| DocError::Invalid("body".into()))?;
|
||||
made.push(kernel::patch_edges(&b.body, edges)?);
|
||||
}
|
||||
if made.is_empty() {
|
||||
return Err(DocError::Invalid("a patch needs a sketch region or a loop of edges".into()));
|
||||
}
|
||||
for (k, b) in made.into_iter().enumerate() {
|
||||
let name = new_name(st, f, k);
|
||||
st.bodies.push(ModelBody::new(name, b, f.id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Stitch { bodies, tolerance } => {
|
||||
let tol = val(vals, tolerance, Kind::Length)?;
|
||||
let idx: Vec<usize> = bodies
|
||||
.iter()
|
||||
.map(|n| st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`"))))
|
||||
.collect::<Result<_>>()?;
|
||||
let Some(&first) = idx.first() else { return Err(DocError::Invalid("pick the surfaces to stitch".into())) };
|
||||
let refs: Vec<&kernel::Body> = idx.iter().filter_map(|i| st.bodies.get(*i).map(|b| &b.body)).collect();
|
||||
let sewn = kernel::stitch(&refs, tol)?;
|
||||
let Some(mb) = st.bodies.get(first).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
if let Some(slot) = st.bodies.get_mut(first) {
|
||||
*slot = ModelBody::new(mb.name, sewn, mb.feature);
|
||||
}
|
||||
// The others are now part of the first.
|
||||
let gone: Vec<String> = idx.iter().skip(1).filter_map(|i| st.bodies.get(*i).map(|b| b.name.clone())).collect();
|
||||
st.bodies.retain(|b| !gone.contains(&b.name));
|
||||
Ok(())
|
||||
}
|
||||
FeatureKind::Thicken { bodies, thickness, symmetric, operation, targets } => {
|
||||
let t = val(vals, thickness, Kind::Length)?;
|
||||
let mut tools = Vec::new();
|
||||
for n in bodies {
|
||||
let b = st.body(n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))?;
|
||||
tools.push(kernel::thicken(&b.body, t, *symmetric)?);
|
||||
}
|
||||
if tools.is_empty() {
|
||||
return Err(DocError::Invalid("pick the surfaces to thicken".into()));
|
||||
}
|
||||
apply_op(st, f, tools, *operation, targets)
|
||||
}
|
||||
FeatureKind::SurfaceTrim { body, plane, tool, keep } => {
|
||||
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 trimmed = match tool {
|
||||
Some(t) => {
|
||||
let tb = st.body(&t.body).ok_or_else(|| DocError::Unknown(format!("body `{}`", t.body)))?;
|
||||
let face = crate::appearance::face_index_at(tb, t.point)
|
||||
.ok_or_else(|| DocError::Invalid(format!("no face of `{}` at the tool point", t.body)))?;
|
||||
kernel::trim(&mb.body, &kernel::SplitTool::Face { body: &tb.body, face }, *keep)?
|
||||
}
|
||||
None => kernel::trim(&mb.body, &kernel::SplitTool::Plane(doc.resolve_plane(vals, plane, 0)?), *keep)?,
|
||||
};
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::new(mb.name, trimmed, mb.feature);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(DocError::Invalid("not a surface feature".into())),
|
||||
}
|
||||
}
|
||||
@@ -223,6 +223,10 @@ impl FeatureKind {
|
||||
}
|
||||
}
|
||||
FeatureKind::ReplaceFace { target, .. } => plane_inputs_mut(target, &mut v),
|
||||
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::Patch { .. } => {}
|
||||
FeatureKind::Combine { .. }
|
||||
| FeatureKind::SheetBase { .. }
|
||||
| FeatureKind::SheetUnfold { .. }
|
||||
|
||||
@@ -35,6 +35,7 @@ mod sketch_import;
|
||||
mod sketch_inspect;
|
||||
mod sketch_modify;
|
||||
mod sketch_project;
|
||||
mod surface;
|
||||
mod surface_analysis;
|
||||
|
||||
use serde::Serialize;
|
||||
@@ -139,6 +140,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
|
||||
v.extend(sketch_canvas::COMMANDS.iter());
|
||||
v.extend(sketch_3d::COMMANDS.iter());
|
||||
v.extend(surface_analysis::COMMANDS.iter());
|
||||
v.extend(surface::COMMANDS.iter());
|
||||
v.extend(sketch_modify::COMMANDS.iter());
|
||||
v.extend(sketch_project::COMMANDS.iter());
|
||||
v.extend(features::COMMANDS.iter());
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Surface commands: Patch, Stitch, Thicken and Trim (surface bodies and turning them into
|
||||
//! solids).
|
||||
|
||||
use serde_json::Value;
|
||||
use solvecraft_doc::{FaceAt, FeatureKind, PlaneRef, ProfileSel, expr::Kind};
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::features::{add_feature, check_expr, feature_sketch, operation, plane_param, profiles};
|
||||
use crate::params::{bad, bool_, expr, str_, string_list, vec3};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("FusionSurfacePatchCommand", "Patch", patch)
|
||||
.at("SURFACE", "CREATE")
|
||||
.icon("patch")
|
||||
.params("sketch?: id|name and profiles? (planar patches of sketch regions) | edges: [[x,y,z]] (points on a closed loop of a body's edges) and body?; name?, body_name?"),
|
||||
CommandSpec::new("FusionSurfaceStitchCommand", "Stitch", stitch)
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("stitch")
|
||||
.params("bodies: [names] (surfaces sewn into the first; a solid when they close); tolerance?: expr (default: tight)"),
|
||||
CommandSpec::new("FusionSurfaceThickenCommand", "Thicken", thicken)
|
||||
.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("FusionSurfaceTrimCommand", "Trim", trim)
|
||||
.at("SURFACE", "MODIFY")
|
||||
.icon("trim")
|
||||
.params("body: surface name; plane: XY|XZ|YZ|plane name|{origin, normal} or tool: {body, point} (a face, its surface extended); keep: [x,y,z] (a point on the side to keep)"),
|
||||
];
|
||||
|
||||
fn patch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "FusionSurfacePatchCommand";
|
||||
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() {
|
||||
if edges.len() > 10_000 {
|
||||
return Err(bad(cmd, "too many edges"));
|
||||
}
|
||||
let body = str_(p, "body").map(str::to_string);
|
||||
return add_feature(s, p, FeatureKind::Patch { sketch: None, profiles: ProfileSel::All, body, edges });
|
||||
}
|
||||
let sketch = feature_sketch(s, p, cmd)?;
|
||||
let profiles = profiles(p, cmd)?;
|
||||
add_feature(s, p, FeatureKind::Patch { sketch: Some(sketch), profiles, body: None, edges: Vec::new() })
|
||||
}
|
||||
|
||||
fn bodies_param(s: &Session, p: &Value, cmd: &str) -> Result<Vec<String>> {
|
||||
let bodies = string_list(p, "bodies");
|
||||
if bodies.is_empty() {
|
||||
return Err(bad(cmd, "`bodies` must list bodies"));
|
||||
}
|
||||
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(bodies)
|
||||
}
|
||||
|
||||
fn stitch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "FusionSurfaceStitchCommand";
|
||||
let bodies = bodies_param(s, p, cmd)?;
|
||||
let tolerance = expr(p, "tolerance").unwrap_or_else(|| "0".into());
|
||||
check_expr(s, &tolerance, Kind::Length, cmd, "tolerance")?;
|
||||
add_feature(s, p, FeatureKind::Stitch { bodies, tolerance })
|
||||
}
|
||||
|
||||
fn thicken(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "FusionSurfaceThickenCommand";
|
||||
let bodies = bodies_param(s, p, cmd)?;
|
||||
let thickness = expr(p, "thickness").ok_or_else(|| bad(cmd, "`thickness` is required"))?;
|
||||
check_expr(s, &thickness, Kind::Length, cmd, "thickness")?;
|
||||
let kind = FeatureKind::Thicken {
|
||||
bodies,
|
||||
thickness,
|
||||
symmetric: bool_(p, "symmetric").unwrap_or(false),
|
||||
operation: operation(p, cmd)?,
|
||||
targets: string_list(p, "targets"),
|
||||
};
|
||||
add_feature(s, p, kind)
|
||||
}
|
||||
|
||||
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();
|
||||
if s.model.state().body(&body).is_none() {
|
||||
return Err(bad(cmd, format!("no body `{body}`")));
|
||||
}
|
||||
let keep = p.get("keep").and_then(vec3).ok_or_else(|| bad(cmd, "`keep` must be [x, y, z] on the side to keep"))?;
|
||||
let (plane, tool) = match p.get("tool").filter(|t| !t.is_null()) {
|
||||
Some(t) => {
|
||||
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(vec3).ok_or_else(|| bad(cmd, "`tool.point` must be [x, y, z] on the tool face"))?;
|
||||
(PlaneRef::Origin { name: "XY".into() }, Some(FaceAt { body: tb, point }))
|
||||
}
|
||||
None => (plane_param(s, p.get("plane"), cmd)?, None),
|
||||
};
|
||||
add_feature(s, p, FeatureKind::SurfaceTrim { body, plane, tool, keep })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn run(s: &mut Session, c: &str, p: Value) -> Value {
|
||||
s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}"))
|
||||
}
|
||||
fn volume(s: &Session, name: &str) -> f64 {
|
||||
let st = s.world_state();
|
||||
solvecraft_kernel::measure(&st.body(name).unwrap().body).unwrap().volume
|
||||
}
|
||||
|
||||
/// A rectangle patched, trimmed by a plane and thickened; a box's faces stitched back.
|
||||
#[test]
|
||||
fn patch_trim_thicken_and_stitch() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "SketchCreate", json!({"plane": "XY", "name": "S"}));
|
||||
run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [10, 10]}));
|
||||
run(&mut s, "SketchStop", json!({}));
|
||||
run(&mut s, "FusionSurfacePatchCommand", json!({"sketch": "S", "body_name": "Sheet"}));
|
||||
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, "FusionSurfaceThickenCommand", json!({"bodies": ["Sheet"], "thickness": "2 mm", "body_name": "Slab"}));
|
||||
let v = volume(&s, "Slab");
|
||||
assert!((v - 140.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(
|
||||
&mut s,
|
||||
"FusionSurfacePatchCommand",
|
||||
json!({"edges": [[25, 0, 10], [30, 5, 10], [25, 10, 10], [20, 5, 10]], "body": "Cup", "body_name": "Lid"}),
|
||||
);
|
||||
assert!(s.world_state().body("Lid").unwrap().body.is_surface());
|
||||
assert!(s.execute("FusionSurfaceThickenCommand", &json!({"bodies": ["Nope"], "thickness": 1})).is_err());
|
||||
assert!(s.execute("FusionSurfaceTrimCommand", &json!({"body": "Sheet"})).is_err());
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ mod splitface;
|
||||
mod step;
|
||||
mod step_in;
|
||||
mod step_out;
|
||||
mod surfaces;
|
||||
mod sweep_path;
|
||||
mod topo;
|
||||
|
||||
@@ -56,6 +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 sweep_path::sweep_path;
|
||||
pub use topo::{CylinderFace, cylinder_face_at};
|
||||
pub use topo::{TopoCounts, merged_topology, seam_flags};
|
||||
|
||||
@@ -706,6 +706,34 @@ fn surface_field(s: &mt::Surface) -> Field {
|
||||
Field::Surface(s.clone())
|
||||
}
|
||||
|
||||
/// A plane or circular cylinder behind a surface (for offsetting).
|
||||
pub(crate) enum Analytic {
|
||||
Plane { n: Vec3 },
|
||||
Cylinder { o: Vec3, axis: Vec3, r: f64, out: f64 },
|
||||
}
|
||||
|
||||
pub(crate) fn analytic_field(s: &mt::Surface) -> Option<Analytic> {
|
||||
match surface_field(s) {
|
||||
Field::Plane { n, .. } => Some(Analytic::Plane { n }),
|
||||
Field::Cylinder { o, axis, r, out } => Some(Analytic::Cylinder { o, axis, r, out }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Which side of a tool a point is on.
|
||||
pub(crate) fn side_of_point(tool: &SplitTool, p: Vec3) -> Result<f64> {
|
||||
let f = fields(tool)?;
|
||||
let [g] = f.as_slice() else { return Err(KernelError::Invalid("one tool at a time".into())) };
|
||||
Ok(g.eval(p))
|
||||
}
|
||||
|
||||
/// Which side of a tool a face lies on (its field well inside it).
|
||||
pub(crate) fn face_side(face: &mt::Face, tool: &SplitTool, size: f64) -> Result<f64> {
|
||||
let f = fields(tool)?;
|
||||
let [g] = f.as_slice() else { return Err(KernelError::Invalid("one tool at a time".into())) };
|
||||
side_of(face, g, size)
|
||||
}
|
||||
|
||||
fn fields(tool: &SplitTool) -> Result<Vec<Field>> {
|
||||
Ok(match tool {
|
||||
SplitTool::Plane(p) => vec![Field::Plane { o: p.origin, n: p.normal() }],
|
||||
|
||||
@@ -0,0 +1,570 @@
|
||||
//! Surface bodies (open shells) and the operations that turn them into solids: Patch (a planar
|
||||
//! face filling a closed loop of edges or a sketch region), Stitch (surfaces sharing edges sewn
|
||||
//! into one body, a solid when they close), Trim (a surface cut by a tool, one side kept),
|
||||
//! Thicken (a surface offset along its normals into a solid) and copying faces out of a body.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use mt::{BoundedCurve, InnerSpace, MetricSpace, ParametricCurve, ParametricSurface3D, SearchNearestParameter};
|
||||
use solvecraft_geom::{Plane, Region2, Vec3};
|
||||
use truck_modeling as mt;
|
||||
|
||||
use crate::body::{Body, from_p3, p3};
|
||||
use crate::{KernelError, Result, guard};
|
||||
|
||||
impl Body {
|
||||
/// A surface body: some shell does not close.
|
||||
pub fn is_surface(&self) -> bool {
|
||||
!self.is_mesh() && self.solid.boundaries().iter().any(|s| s.shell_condition() != mt::ShellCondition::Closed)
|
||||
}
|
||||
}
|
||||
|
||||
/// A body of faces as they are (no orientation fix: a surface has no inside).
|
||||
fn surface_body(faces: Vec<mt::Face>) -> Body {
|
||||
let shell = mt::Shell::from(faces);
|
||||
Body { solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), mesh: None, color: None, paint: None }
|
||||
}
|
||||
|
||||
/// A body from faces: a solid when they close, else a surface.
|
||||
fn body_of(faces: Vec<mt::Face>) -> Result<Body> {
|
||||
let shell = mt::Shell::from(faces);
|
||||
if shell.shell_condition() == mt::ShellCondition::Closed {
|
||||
Body::new(mt::Solid::new_unchecked(vec![shell]))
|
||||
} else {
|
||||
Ok(Body { solid: std::sync::Arc::new(mt::Solid::new_unchecked(vec![shell])), mesh: None, color: None, paint: None })
|
||||
}
|
||||
}
|
||||
|
||||
/// A planar surface filling a sketch region.
|
||||
pub fn patch_region(plane: &Plane, region: &Region2) -> Result<Body> {
|
||||
let f = crate::build::face(plane, region)?;
|
||||
Ok(surface_body(vec![f]))
|
||||
}
|
||||
|
||||
/// The edge of a body nearest a point (by samples along each edge).
|
||||
fn edge_near(body: &Body, p: Vec3) -> Option<mt::Edge> {
|
||||
let mut best: Option<(f64, mt::Edge)> = None;
|
||||
for e in body.solid.edge_iter() {
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
for k in 0..=32 {
|
||||
let d = from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 32.0)).dist(p);
|
||||
if best.as_ref().is_none_or(|(bd, _)| d < *bd) {
|
||||
best = Some((d, e.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.filter(|(d, _)| *d <= body.size() * 0.05 + 1e-6).map(|(_, e)| e)
|
||||
}
|
||||
|
||||
/// A planar surface filling a closed loop of a body's edges (each picked by a point on it).
|
||||
pub fn patch_edges(body: &Body, at: &[Vec3]) -> Result<Body> {
|
||||
body.require_brep("patch")?;
|
||||
if at.is_empty() || at.len() > 10_000 {
|
||||
return Err(KernelError::Invalid("pick the edges of a closed loop".into()));
|
||||
}
|
||||
let mut edges: Vec<mt::Edge> = Vec::new();
|
||||
for p in at {
|
||||
let e = edge_near(body, *p).ok_or_else(|| KernelError::Invalid(format!("no edge at {p:?}")))?;
|
||||
if !edges.iter().any(|x| x.id() == e.id()) {
|
||||
edges.push(e);
|
||||
}
|
||||
}
|
||||
guard("patch", || {
|
||||
// Chain the edges into one loop (turning them round as needed).
|
||||
let mut pool = edges;
|
||||
let first = pool.pop().ok_or_else(|| KernelError::Invalid("no edges".into()))?;
|
||||
let start = first.front().id();
|
||||
let mut at_v = first.back().clone();
|
||||
let mut chain = vec![first];
|
||||
while at_v.id() != start {
|
||||
let k = pool
|
||||
.iter()
|
||||
.position(|e| e.front().id() == at_v.id() || e.back().id() == at_v.id())
|
||||
.ok_or_else(|| KernelError::Invalid("the edges do not form a closed loop".into()))?;
|
||||
let e = pool.remove(k);
|
||||
let e = if e.front().id() == at_v.id() { e } else { e.inverse() };
|
||||
at_v = e.back().clone();
|
||||
chain.push(e);
|
||||
}
|
||||
if !pool.is_empty() {
|
||||
return Err(KernelError::Invalid("the edges form more than one loop".into()));
|
||||
}
|
||||
let wire: mt::Wire = chain.into();
|
||||
let face = mt::builder::try_attach_plane(&[wire])
|
||||
.map_err(|_| KernelError::Invalid("not supported yet: patching a loop that is not planar".into()))?;
|
||||
Ok(surface_body(vec![face]))
|
||||
})
|
||||
}
|
||||
|
||||
/// Faces of a body copied out as a surface body (faces picked by points on them).
|
||||
pub fn copy_faces(body: &Body, at: &[Vec3]) -> Result<Body> {
|
||||
body.require_brep("copy faces")?;
|
||||
let faces: Vec<&mt::Face> = body.solid.face_iter().collect();
|
||||
let mut chosen = Vec::new();
|
||||
for p in at {
|
||||
// The face whose mesh passes nearest the point.
|
||||
let i = nearest_face(body, *p)?;
|
||||
if !chosen.contains(&i) {
|
||||
chosen.push(i);
|
||||
}
|
||||
}
|
||||
Ok(surface_body(chosen.into_iter().filter_map(|i| faces.get(i).map(|f| (*f).clone())).collect()))
|
||||
}
|
||||
|
||||
/// Index of the face nearest a point (over its mesh triangles).
|
||||
fn nearest_face(body: &Body, p: Vec3) -> Result<usize> {
|
||||
let m = body.tessellate(body.size() * 2e-3)?;
|
||||
let mut best: Option<(f64, u32)> = None;
|
||||
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
|
||||
let Some([a, b, c]) = m.tri(t) else { continue };
|
||||
let d = ((a + b + c) / 3.0).dist(p).min(a.dist(p)).min(b.dist(p)).min(c.dist(p));
|
||||
if best.is_none_or(|(x, _)| d < x) {
|
||||
best = Some((d, *f));
|
||||
}
|
||||
}
|
||||
best.map(|(_, f)| f as usize).ok_or_else(|| KernelError::Invalid("no face there".into()))
|
||||
}
|
||||
|
||||
/// Sew surface bodies along edges that coincide (within `tol`): one body, a solid when the
|
||||
/// faces close up. Faces are turned so that neighbours agree.
|
||||
pub fn stitch(bodies: &[&Body], tol: f64) -> Result<Body> {
|
||||
if bodies.is_empty() {
|
||||
return Err(KernelError::Invalid("nothing to stitch".into()));
|
||||
}
|
||||
for b in bodies {
|
||||
b.require_brep("stitch")?;
|
||||
}
|
||||
let size = bodies.iter().map(|b| b.size()).fold(0.0, f64::max);
|
||||
let tol = if tol.is_finite() && tol > 0.0 { tol } else { size * 1e-6 + 1e-6 };
|
||||
guard("stitch", || {
|
||||
let faces: Vec<mt::Face> = bodies.iter().flat_map(|b| b.solid.face_iter().cloned().collect::<Vec<_>>()).collect();
|
||||
// Shared vertices by position, shared edges by their ends and middle.
|
||||
let mut verts: Vec<mt::Vertex> = Vec::new();
|
||||
let mut vmap: HashMap<mt::VertexID, mt::Vertex> = HashMap::new();
|
||||
let mut canon = |v: &mt::Vertex, verts: &mut Vec<mt::Vertex>| -> mt::Vertex {
|
||||
if let Some(c) = vmap.get(&v.id()) {
|
||||
return c.clone();
|
||||
}
|
||||
let p = v.point();
|
||||
let c = match verts.iter().find(|u| u.point().distance(p) <= tol) {
|
||||
Some(u) => u.clone(),
|
||||
None => {
|
||||
verts.push(v.clone());
|
||||
v.clone()
|
||||
}
|
||||
};
|
||||
vmap.insert(v.id(), c.clone());
|
||||
c
|
||||
};
|
||||
let mut edges: Vec<(mt::Edge, mt::Point3)> = Vec::new();
|
||||
let mut out = Vec::with_capacity(faces.len());
|
||||
for f in &faces {
|
||||
let mut wires = Vec::new();
|
||||
for w in f.absolute_boundaries() {
|
||||
let mut nw = Vec::new();
|
||||
for e in w.edge_iter() {
|
||||
let abs = if e.orientation() { e.clone() } else { e.inverse() };
|
||||
let (a, b) = (canon(abs.front(), &mut verts), canon(abs.back(), &mut verts));
|
||||
let c = abs.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
let mid = c.subs(0.5 * (t0 + t1));
|
||||
let found = edges.iter().find_map(|(x, m)| {
|
||||
if m.distance(mid) > tol * 10.0 {
|
||||
return None;
|
||||
}
|
||||
if x.front().id() == a.id() && x.back().id() == b.id() {
|
||||
Some(x.clone())
|
||||
} else if x.front().id() == b.id() && x.back().id() == a.id() {
|
||||
Some(x.inverse())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let ne = match found {
|
||||
Some(x) => x,
|
||||
None => {
|
||||
let x = mt::Edge::new_unchecked(&a, &b, c);
|
||||
edges.push((x.clone(), mid));
|
||||
x
|
||||
}
|
||||
};
|
||||
nw.push(if e.orientation() { ne } else { ne.inverse() });
|
||||
}
|
||||
wires.push(mt::Wire::from(nw));
|
||||
}
|
||||
let mut nf = mt::Face::new_unchecked(wires, f.surface());
|
||||
if !f.orientation() {
|
||||
nf.invert();
|
||||
}
|
||||
out.push(nf);
|
||||
}
|
||||
// Neighbours run their shared edges opposite ways.
|
||||
let mut uses: HashMap<mt::EdgeID, Vec<(usize, bool)>> = HashMap::new();
|
||||
for (i, f) in out.iter().enumerate() {
|
||||
for e in f.boundaries().iter().flat_map(|w| w.edge_iter()) {
|
||||
uses.entry(e.id()).or_default().push((i, e.orientation()));
|
||||
}
|
||||
}
|
||||
let mut flip: Vec<Option<bool>> = vec![None; out.len()];
|
||||
for s in 0..out.len() {
|
||||
if flip[s].is_some() {
|
||||
continue;
|
||||
}
|
||||
flip[s] = Some(false);
|
||||
let mut stack = vec![s];
|
||||
while let Some(i) = stack.pop() {
|
||||
let fl = flip[i].unwrap_or(false);
|
||||
let Some(f) = out.get(i) else { continue };
|
||||
for e in f.boundaries().iter().flat_map(|w| w.edge_iter()) {
|
||||
let d = e.orientation() != fl;
|
||||
for &(j, dj) in uses.get(&e.id()).into_iter().flatten() {
|
||||
if j != i && flip[j].is_none() {
|
||||
flip[j] = Some(dj == d);
|
||||
stack.push(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let out: Vec<mt::Face> = out.into_iter().zip(flip).map(|(f, fl)| if fl == Some(true) { f.inverse() } else { f }).collect();
|
||||
body_of(out)
|
||||
})
|
||||
}
|
||||
|
||||
/// How a face moves along its normal: a plane slides, a circular cylinder changes radius.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Offset {
|
||||
Plane { n: Vec3 },
|
||||
Cylinder { o: Vec3, axis: Vec3, r: f64, out: f64 },
|
||||
}
|
||||
|
||||
impl Offset {
|
||||
fn of(f: &mt::Face) -> Option<Offset> {
|
||||
match crate::splitface::analytic_field(&f.oriented_surface())? {
|
||||
crate::splitface::Analytic::Plane { n } => Some(Offset::Plane { n }),
|
||||
crate::splitface::Analytic::Cylinder { o, axis, r, out } => Some(Offset::Cylinder { o, axis, r, out }),
|
||||
}
|
||||
}
|
||||
fn map(&self, p: Vec3, d: f64) -> Vec3 {
|
||||
match *self {
|
||||
Offset::Plane { n } => p + n * d,
|
||||
// Scaled about the axis (control points of a NURBS circle stay exact).
|
||||
Offset::Cylinder { o, axis, r, out } => {
|
||||
let v = p - o;
|
||||
let radial = v - axis * v.dot(axis);
|
||||
p + radial * (out * d / r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_pt(off: &Offset, p: mt::Point3, d: f64) -> mt::Point3 {
|
||||
p3(off.map(from_p3(p), d))
|
||||
}
|
||||
|
||||
/// A curve moved by an offset (control points mapped; exact for lines, and for circles square
|
||||
/// to a cylinder's axis or in a plane).
|
||||
fn map_curve(off: &Offset, c: &mt::Curve, d: f64) -> Result<mt::Curve> {
|
||||
Ok(match c {
|
||||
mt::Curve::Line(l) => mt::Curve::Line(mt::Line(map_pt(off, l.0, d), map_pt(off, l.1, d))),
|
||||
mt::Curve::BSplineCurve(b) => {
|
||||
let pts: Vec<mt::Point3> = b.control_points().iter().map(|p| map_pt(off, *p, d)).collect();
|
||||
mt::Curve::BSplineCurve(mt::BSplineCurve::new(b.knot_vec().clone(), pts))
|
||||
}
|
||||
mt::Curve::NurbsCurve(n) => {
|
||||
let nb = n.non_rationalized();
|
||||
let pts: Vec<mt::Vector4> = nb
|
||||
.control_points()
|
||||
.iter()
|
||||
.map(|h| {
|
||||
let w = h.w;
|
||||
let q = map_pt(off, mt::Point3::new(h.x / w, h.y / w, h.z / w), d);
|
||||
mt::Vector4::new(q.x * w, q.y * w, q.z * w, w)
|
||||
})
|
||||
.collect();
|
||||
mt::Curve::NurbsCurve(mt::NurbsCurve::new(mt::BSplineCurve::new(nb.knot_vec().clone(), pts)))
|
||||
}
|
||||
other => {
|
||||
// Anything else: a cubic through moved samples.
|
||||
let (t0, t1) = other.range_tuple();
|
||||
let pts: Vec<Vec3> = (0..=32).map(|k| off.map(from_p3(other.subs(t0 + (t1 - t0) * k as f64 / 32.0)), d)).collect();
|
||||
mt::Curve::BSplineCurve(crate::build::interpolate_cubic(&pts).ok_or_else(|| KernelError::Failed("offset edge".into()))?)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A surface moved by an offset (control points mapped; exact for planes and NURBS cylinders).
|
||||
fn map_surface(off: &Offset, s: &mt::Surface, d: f64) -> Result<mt::Surface> {
|
||||
Ok(match s {
|
||||
mt::Surface::Plane(pl) => {
|
||||
let (o, a, b) = (pl.origin(), pl.u_axis(), pl.v_axis());
|
||||
let o2 = map_pt(off, o, d);
|
||||
mt::Surface::Plane(mt::Plane::new(o2, o2 + a, o2 + b))
|
||||
}
|
||||
mt::Surface::BSplineSurface(b) => {
|
||||
let rows: Vec<Vec<mt::Point3>> = b.control_points().iter().map(|r| r.iter().map(|p| map_pt(off, *p, d)).collect()).collect();
|
||||
mt::Surface::BSplineSurface(mt::BSplineSurface::new((b.uknot_vec().clone(), b.vknot_vec().clone()), rows))
|
||||
}
|
||||
mt::Surface::NurbsSurface(n) => {
|
||||
let nb = n.non_rationalized();
|
||||
let rows: Vec<Vec<mt::Vector4>> = nb
|
||||
.control_points()
|
||||
.iter()
|
||||
.map(|r| {
|
||||
r.iter()
|
||||
.map(|h| {
|
||||
let w = h.w;
|
||||
let q = map_pt(off, mt::Point3::new(h.x / w, h.y / w, h.z / w), d);
|
||||
mt::Vector4::new(q.x * w, q.y * w, q.z * w, w)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
mt::Surface::NurbsSurface(mt::NurbsSurface::new(mt::BSplineSurface::new((nb.uknot_vec().clone(), nb.vknot_vec().clone()), rows)))
|
||||
}
|
||||
_ => return Err(KernelError::Invalid("not supported yet: thickening this kind of surface".into())),
|
||||
})
|
||||
}
|
||||
|
||||
/// A surface body thickened into a solid: offset along the faces' normals by `thickness`
|
||||
/// (negative: against them), or by half each way when `symmetric`. Planar faces and faces on
|
||||
/// circular cylinders; neighbouring faces must meet smoothly (their offsets agree).
|
||||
pub fn thicken(body: &Body, thickness: f64, symmetric: bool) -> Result<Body> {
|
||||
body.require_brep("thicken")?;
|
||||
if !(thickness.is_finite() && thickness.abs() > 1e-9) {
|
||||
return Err(KernelError::Invalid("thickness must be non-zero".into()));
|
||||
}
|
||||
let (d0, d1) = if symmetric { (-thickness / 2.0, thickness / 2.0) } else { (0.0, thickness) };
|
||||
let (lo, hi) = (d0.min(d1), d0.max(d1));
|
||||
let size = body.size();
|
||||
let tol = size * 1e-7 + 1e-9;
|
||||
guard("thicken", || {
|
||||
let faces: Vec<mt::Face> = body.solid.face_iter().cloned().collect();
|
||||
if faces.is_empty() {
|
||||
return Err(KernelError::Invalid("nothing to thicken".into()));
|
||||
}
|
||||
let offs: Vec<Offset> = faces
|
||||
.iter()
|
||||
.map(|f| {
|
||||
Offset::of(f).ok_or_else(|| KernelError::Invalid("not supported yet: thickening faces that are not planar or cylindrical".into()))
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
// Each vertex and edge moved to both sides (once; every face using it must agree).
|
||||
let mut vmoved: HashMap<mt::VertexID, (mt::Vertex, mt::Vertex)> = HashMap::new();
|
||||
let mut emoved: HashMap<mt::EdgeID, (mt::Edge, mt::Edge)> = HashMap::new();
|
||||
let mut uses: HashMap<mt::EdgeID, usize> = HashMap::new();
|
||||
for (f, off) in faces.iter().zip(&offs) {
|
||||
for e in f.absolute_boundaries().iter().flat_map(|w| w.edge_iter()) {
|
||||
let abs = if e.orientation() { e.clone() } else { e.inverse() };
|
||||
*uses.entry(abs.id()).or_default() += 1;
|
||||
for v in [abs.front(), abs.back()] {
|
||||
let (a, b) = (map_pt(off, v.point(), lo), map_pt(off, v.point(), hi));
|
||||
match vmoved.get(&v.id()) {
|
||||
Some((va, vb)) => {
|
||||
if va.point().distance(a) > tol * 1e3 || vb.point().distance(b) > tol * 1e3 {
|
||||
return Err(KernelError::Invalid("not supported yet: thickening across a sharp edge between faces".into()));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
vmoved.insert(v.id(), (mt::Vertex::new(a), mt::Vertex::new(b)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let std::collections::hash_map::Entry::Vacant(slot) = emoved.entry(abs.id()) {
|
||||
let (Some((a0, a1)), Some((b0, b1))) = (vmoved.get(&abs.front().id()).cloned(), vmoved.get(&abs.back().id()).cloned()) else {
|
||||
continue;
|
||||
};
|
||||
let c = abs.curve();
|
||||
let lo_e = mt::Edge::new_unchecked(&a0, &b0, map_curve(off, &c, lo)?);
|
||||
let hi_e = mt::Edge::new_unchecked(&a1, &b1, map_curve(off, &c, hi)?);
|
||||
slot.insert((lo_e, hi_e));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Lines joining each vertex's two copies.
|
||||
let mut sides: HashMap<mt::VertexID, mt::Edge> = HashMap::new();
|
||||
for (id, (a, b)) in &vmoved {
|
||||
sides.insert(*id, mt::Edge::new_unchecked(a, b, mt::Curve::Line(mt::Line(a.point(), b.point()))));
|
||||
}
|
||||
let moved_face = |f: &mt::Face, off: &Offset, which: usize, d: f64| -> Result<mt::Face> {
|
||||
let wires: Vec<mt::Wire> = f
|
||||
.absolute_boundaries()
|
||||
.iter()
|
||||
.map(|w| {
|
||||
w.edge_iter()
|
||||
.filter_map(|e| {
|
||||
let abs_id = if e.orientation() { e.id() } else { e.inverse().id() };
|
||||
let (a, b) = emoved.get(&abs_id)?;
|
||||
let m = if which == 0 { a.clone() } else { b.clone() };
|
||||
Some(if e.orientation() { m } else { m.inverse() })
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
let mut nf = mt::Face::new_unchecked(wires, map_surface(off, &f.surface(), d)?);
|
||||
if !f.orientation() {
|
||||
nf.invert();
|
||||
}
|
||||
Ok(nf)
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for (f, off) in faces.iter().zip(&offs) {
|
||||
// The lower copy faces back, the upper one forward.
|
||||
out.push(moved_face(f, off, 0, lo)?.inverse());
|
||||
out.push(moved_face(f, off, 1, hi)?);
|
||||
// Walls along free edges, outward across the edge.
|
||||
for w in f.boundaries() {
|
||||
for e in w.edge_iter() {
|
||||
let abs_id = if e.orientation() { e.id() } else { e.inverse().id() };
|
||||
if uses.get(&abs_id).copied().unwrap_or(0) != 1 {
|
||||
continue;
|
||||
}
|
||||
let Some((le, he)) = emoved.get(&abs_id) else { continue };
|
||||
let (le, he) = if e.orientation() { (le.clone(), he.clone()) } else { (le.inverse(), he.inverse()) };
|
||||
let (Some(sa), Some(sb)) = (sides.get(&e.front().id()), sides.get(&e.back().id())) else { continue };
|
||||
// A → B low, B → B' up, B' → A' high back, A' → A down.
|
||||
let wire: mt::Wire = vec![le.clone(), sb.clone(), he.inverse(), sa.inverse()].into();
|
||||
let ruled = mt::builder::homotopy(&le, &he);
|
||||
let surf = ruled.surface();
|
||||
// The wall's outward normal: across the edge, away from the face.
|
||||
let c = le.oriented_curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
let tm = 0.5 * (t0 + t1);
|
||||
let p = c.subs(tm);
|
||||
let tangent = c.der(tm);
|
||||
let n =
|
||||
f.oriented_surface().search_nearest_parameter(p, None, 50).map(|(u, v)| f.oriented_surface().normal(u, v)).unwrap_or(tangent);
|
||||
let outward = tangent.cross(n);
|
||||
let sn = surf.search_nearest_parameter(p, None, 50).map(|(u, v)| surf.normal(u, v)).unwrap_or(outward);
|
||||
let wall = if sn.dot(outward) >= 0.0 {
|
||||
mt::Face::new_unchecked(vec![wire], surf)
|
||||
} else {
|
||||
let mut x = mt::Face::new_unchecked(vec![wire.inverse()], surf);
|
||||
x.invert();
|
||||
x
|
||||
};
|
||||
out.push(wall);
|
||||
}
|
||||
}
|
||||
}
|
||||
let shell = mt::Shell::from(out);
|
||||
if shell.shell_condition() != mt::ShellCondition::Closed {
|
||||
return Err(KernelError::Failed("the thickened surface does not close".into()));
|
||||
}
|
||||
Body::new(mt::Solid::new_unchecked(vec![shell]))
|
||||
})
|
||||
}
|
||||
|
||||
/// A surface body trimmed by a tool: its faces are cut where the tool meets them and the
|
||||
/// pieces on the side of `keep` are kept.
|
||||
pub fn trim(body: &Body, tool: &crate::SplitTool, keep: Vec3) -> Result<Body> {
|
||||
body.require_brep("trim")?;
|
||||
let total = body.solid.face_iter().count();
|
||||
let all: Vec<usize> = (0..total).collect();
|
||||
let split = crate::split_faces(body, &all, tool)?;
|
||||
let side = crate::splitface::side_of_point(tool, keep)?;
|
||||
guard("trim", || {
|
||||
let mut kept = Vec::new();
|
||||
for f in split.solid.face_iter() {
|
||||
let x = crate::splitface::face_side(f, tool, split.size())?;
|
||||
if (x >= 0.0) == (side >= 0.0) {
|
||||
kept.push(f.clone());
|
||||
}
|
||||
}
|
||||
if kept.is_empty() {
|
||||
return Err(KernelError::Invalid("nothing is left on that side of the tool".into()));
|
||||
}
|
||||
Ok(surface_body(kept))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{box_solid, cylinder, measure};
|
||||
use solvecraft_geom::{Loop2, Vec2};
|
||||
|
||||
fn rel(a: f64, b: f64) -> f64 {
|
||||
(a - b).abs() / b.abs().max(1e-9)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn patch_and_thicken_a_square() {
|
||||
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();
|
||||
assert!(s.is_surface());
|
||||
let t = thicken(&s, 2.0, false).unwrap();
|
||||
assert!(!t.is_surface());
|
||||
assert!(rel(measure(&t).unwrap().volume, 200.0) < 1e-9);
|
||||
let t = thicken(&s, 2.0, true).unwrap();
|
||||
let m = measure(&t).unwrap();
|
||||
assert!(rel(m.volume, 200.0) < 1e-9 && (m.bbox.min.z + 1.0).abs() < 1e-9, "{:?}", m.bbox);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thicken_a_cylinder_side_and_a_box_top() {
|
||||
let c = cylinder(Vec3::ZERO, Vec3::Z, 10.0, 20.0).unwrap();
|
||||
// One half of the side, offset outward by 1: half of a ring.
|
||||
let half = copy_faces(&c, &[Vec3::new(7.0, 7.0, 10.0)]).unwrap();
|
||||
let v = measure(&thicken(&half, 1.0, false).unwrap()).unwrap().volume;
|
||||
let want = std::f64::consts::PI * (121.0 - 100.0) / 2.0 * 20.0;
|
||||
assert!(rel(v, want) < 3e-4, "{v} vs {want}"); // (measured on meshes)
|
||||
// Both halves (meeting smoothly): the whole ring, inward.
|
||||
let d = 10.0 / 2f64.sqrt();
|
||||
let side = copy_faces(&c, &[Vec3::new(d, d, 10.0), Vec3::new(-d, -d, 10.0)]).unwrap();
|
||||
assert_eq!(side.face_count(), 2);
|
||||
let v = measure(&thicken(&side, -1.0, false).unwrap()).unwrap().volume;
|
||||
let want = std::f64::consts::PI * (100.0 - 81.0) * 20.0;
|
||||
assert!(rel(v, want) < 3e-4, "{v} vs {want}"); // (measured on meshes)
|
||||
// Faces meeting at a sharp edge are refused.
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap();
|
||||
let two = copy_faces(&b, &[Vec3::new(5.0, 5.0, 10.0), Vec3::new(5.0, 0.0, 5.0)]).unwrap();
|
||||
assert!(thicken(&two, 1.0, false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stitch_six_faces_into_a_box_and_patch_a_hole() {
|
||||
let b = box_solid(Vec3::ZERO, Vec3::new(10.0, 20.0, 30.0)).unwrap();
|
||||
let pts = [
|
||||
Vec3::new(5.0, 10.0, 0.0),
|
||||
Vec3::new(5.0, 10.0, 30.0),
|
||||
Vec3::new(0.0, 10.0, 15.0),
|
||||
Vec3::new(10.0, 10.0, 15.0),
|
||||
Vec3::new(5.0, 0.0, 15.0),
|
||||
Vec3::new(5.0, 20.0, 15.0),
|
||||
];
|
||||
let parts: Vec<Body> = pts.iter().map(|p| copy_faces(&b, &[*p]).unwrap()).collect();
|
||||
let refs: Vec<&Body> = parts.iter().collect();
|
||||
let s = stitch(&refs, 0.0).unwrap();
|
||||
assert!(!s.is_surface());
|
||||
assert!(rel(measure(&s).unwrap().volume, 6000.0) < 1e-9);
|
||||
// Five faces leave an open box; patching the top's edges closes it again.
|
||||
let open = stitch(&[refs[0], refs[2], refs[3], refs[4], refs[5]], 0.0).unwrap();
|
||||
assert!(open.is_surface());
|
||||
let lid =
|
||||
patch_edges(&open, &[Vec3::new(5.0, 0.0, 30.0), Vec3::new(10.0, 10.0, 30.0), Vec3::new(5.0, 20.0, 30.0), Vec3::new(0.0, 10.0, 30.0)])
|
||||
.unwrap();
|
||||
let closed = stitch(&[&open, &lid], 0.0).unwrap();
|
||||
assert!(!closed.is_surface());
|
||||
assert!(rel(measure(&closed).unwrap().volume, 6000.0) < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_keeps_one_side() {
|
||||
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 pl = Plane::new(Vec3::new(3.0, 0.0, 0.0), Vec3::Y, Vec3::Z).unwrap();
|
||||
let t = trim(&s, &crate::SplitTool::Plane(pl), Vec3::new(1.0, 5.0, 0.0)).unwrap();
|
||||
assert!(rel(measure(&t).unwrap().area, 30.0) < 1e-9);
|
||||
let t = trim(&s, &crate::SplitTool::Plane(pl), Vec3::new(8.0, 5.0, 0.0)).unwrap();
|
||||
assert!(rel(measure(&t).unwrap().area, 70.0) < 1e-9);
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -2,13 +2,13 @@
|
||||
|
||||
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): 278 / 541 commands live (51%).** SOLID + SKETCH: 167 / 291 (57%). Registered commands: 276.
|
||||
**In scope (SOLID, SKETCH, ASSEMBLY, SHEET METAL, PLASTIC enclosure subset): 279 / 541 commands live (52%).** SOLID + SKETCH: 168 / 291 (58%). Registered commands: 280.
|
||||
|
||||
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: 494 / 1175 (42%).
|
||||
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%).
|
||||
|
||||
| Tab | Panel | Live | Total | % |
|
||||
|---|---|---:|---:|---:|
|
||||
| SOLID | CREATE | 22 | 35 | 63% |
|
||||
| SOLID | CREATE | 23 | 35 | 66% |
|
||||
| SOLID | MODIFY | 18 | 32 | 56% |
|
||||
| SOLID | ASSEMBLE | 12 | 21 | 57% |
|
||||
| SOLID | CONFIGURE | 2 | 3 | 67% |
|
||||
@@ -53,8 +53,8 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE
|
||||
|
||||
| Tab | Panel | Live | Total | % |
|
||||
|---|---|---:|---:|---:|
|
||||
| SURFACE | CREATE | 7 | 25 | 28% |
|
||||
| SURFACE | MODIFY | 13 | 26 | 50% |
|
||||
| SURFACE | CREATE | 9 | 25 | 36% |
|
||||
| SURFACE | MODIFY | 15 | 26 | 58% |
|
||||
| SURFACE | ASSEMBLE | 11 | 20 | 55% |
|
||||
| SURFACE | CONFIGURE | 2 | 3 | 67% |
|
||||
| SURFACE | CONSTRUCT | 2 | 20 | 10% |
|
||||
@@ -84,7 +84,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE
|
||||
| FORM | SELECT | 0 | 15 | 0% |
|
||||
| FORM | FINISH FORM | 0 | 1 | 0% |
|
||||
| PLASTIC | SETUP | 2 | 2 | 100% |
|
||||
| PLASTIC | CREATE | 26 | 35 | 74% |
|
||||
| PLASTIC | CREATE | 27 | 35 | 77% |
|
||||
| PLASTIC | MODIFY | 18 | 32 | 56% |
|
||||
| PLASTIC | ASSEMBLE | 11 | 20 | 55% |
|
||||
| PLASTIC | CONFIGURE | 2 | 3 | 67% |
|
||||
@@ -131,6 +131,7 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE
|
||||
- SOLID › CREATE › Circular Pattern (`PatternCircular`)
|
||||
- SOLID › CREATE › Pattern on Path (`PatternOnPath`)
|
||||
- SOLID › CREATE › Mirror (`MirrorCommand`)
|
||||
- SOLID › CREATE › Thicken (`FusionSurfaceThickenCommand`)
|
||||
- SOLID › CREATE › Joint Origin (`JointOrigin`)
|
||||
- SOLID › MODIFY › Press Pull (`FusionPressPullCommand`)
|
||||
- SOLID › MODIFY › Fillet (`FusionFilletEdgesCommand`)
|
||||
@@ -284,7 +285,6 @@ Deferred until further notice (not in the in-scope figure): MESH, FORM, SURFACE
|
||||
- SOLID › CREATE › Derive (`PushDeriveCommand`)
|
||||
- SOLID › CREATE › Automated Modeling (`AutomatedModelingCommand`)
|
||||
- SOLID › CREATE › Geometric Pattern (`FusionPatternGeometricCommand`)
|
||||
- SOLID › CREATE › Thicken (`FusionSurfaceThickenCommand`)
|
||||
- SOLID › CREATE › Boundary Fill (`SurfaceSculpt`)
|
||||
- SOLID › CREATE › Find Features (`FusionFindFeaturesCommand`)
|
||||
- SOLID › CREATE › Fluid Volume (`EnclosureCommand`)
|
||||
|
||||
Reference in new issue
Block a user