diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index 35b0c730..b6251c0c 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -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); + } } } } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 35548e79..ab832fc9 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -605,6 +605,44 @@ pub enum FeatureKind { Remove { bodies: Vec, }, + /// 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, + #[serde(default = "all_profiles")] + profiles: ProfileSel, + #[serde(default, skip_serializing_if = "Option::is_none")] + body: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + edges: Vec, + }, + /// Surface bodies sewn along shared edges into the first one (a solid when they close). + Stitch { + bodies: Vec, + #[serde(default = "zero_expr")] + tolerance: String, + }, + /// Surface bodies thickened into solids along their normals (by half each way when + /// `symmetric`). + Thicken { + bodies: Vec, + thickness: String, + #[serde(default)] + symmetric: bool, + operation: Operation, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + targets: Vec, + }, + /// 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, + keep: Vec3, + }, Move { bodies: Vec, 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() } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 8e4ee146..0d594a91 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1815,6 +1815,8 @@ fn replay(doc: &Document, vals: &BTreeMap, 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, 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 { .. } diff --git a/crates/doc/src/eval_surface.rs b/crates/doc/src/eval_surface.rs new file mode 100644 index 00000000..8ba3cb69 --- /dev/null +++ b/crates/doc/src/eval_surface.rs @@ -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, 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 = bodies + .iter() + .map(|n| st.bodies.iter().position(|b| &b.name == n).ok_or_else(|| DocError::Unknown(format!("body `{n}`")))) + .collect::>()?; + 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 = 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())), + } +} diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index 04bb9212..b4d50576 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -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 { .. } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index d72170e6..13089238 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/engine/src/cmd/surface.rs b/crates/engine/src/cmd/surface.rs new file mode 100644 index 00000000..92588edb --- /dev/null +++ b/crates/engine/src/cmd/surface.rs @@ -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 { + let cmd = "FusionSurfacePatchCommand"; + let edges: Vec = 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> { + 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 { + 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 { + 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 { + 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()); + } +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 4b7480a8..f1a3c893 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -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}; diff --git a/crates/kernel/src/splitface.rs b/crates/kernel/src/splitface.rs index 9e0ae396..8b32c0fc 100644 --- a/crates/kernel/src/splitface.rs +++ b/crates/kernel/src/splitface.rs @@ -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 { + 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 { + 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 { + 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> { Ok(match tool { SplitTool::Plane(p) => vec![Field::Plane { o: p.origin, n: p.normal() }], diff --git a/crates/kernel/src/surfaces.rs b/crates/kernel/src/surfaces.rs new file mode 100644 index 00000000..8e2f8b8f --- /dev/null +++ b/crates/kernel/src/surfaces.rs @@ -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) -> 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) -> Result { + 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 { + 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 { + 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.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 = 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.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 { + 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 { + 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 = bodies.iter().flat_map(|b| b.solid.face_iter().cloned().collect::>()).collect(); + // Shared vertices by position, shared edges by their ends and middle. + let mut verts: Vec = Vec::new(); + let mut vmap: HashMap = HashMap::new(); + let mut canon = |v: &mt::Vertex, verts: &mut Vec| -> 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> = 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> = 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 = 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 { + 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 { + 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 = 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 = 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 = (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 { + 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> = 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> = 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.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 = body.solid.face_iter().cloned().collect(); + if faces.is_empty() { + return Err(KernelError::Invalid("nothing to thicken".into())); + } + let offs: Vec = 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::>()?; + // Each vertex and edge moved to both sides (once; every face using it must agree). + let mut vmoved: HashMap = HashMap::new(); + let mut emoved: HashMap = HashMap::new(); + let mut uses: HashMap = 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 = 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 { + let wires: Vec = 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::>() + .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.require_brep("trim")?; + let total = body.solid.face_iter().count(); + let all: Vec = (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 = 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); + } +} diff --git a/docs/parity.md b/docs/parity.md index 8fb0765d..a14bd484 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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`)