diff --git a/crates/doc/src/assembly.rs b/crates/doc/src/assembly.rs index cee64fcb..5c6b1c31 100644 --- a/crates/doc/src/assembly.rs +++ b/crates/doc/src/assembly.rs @@ -216,6 +216,7 @@ impl FeatureKind { dir(direction); } FeatureKind::Lip { face, .. } => pt(face), + FeatureKind::BoundaryFill { cells, .. } => cells.iter_mut().for_each(pt), FeatureKind::Rest { position, direction, along, .. } => { pt(position); dir(direction); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 0975952b..e02c4529 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -633,6 +633,17 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "Option::is_none")] body: Option, }, + /// Boundary Fill: solids from the cells the tool bodies enclose (a cell: inside some + /// tools and outside the rest), each picked by a point inside it. + BoundaryFill { + tools: Vec, + cells: Vec, + #[serde(default)] + operation: Operation, + /// Remove the tool bodies afterwards. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + remove_tools: bool, + }, /// Remove bodies from the model (from here on in the timeline). Remove { bodies: Vec, @@ -925,6 +936,7 @@ impl FeatureKind { FeatureKind::Lip { .. } => "GrooveFeature", FeatureKind::SnapFit { .. } => "SnapFitFeature", FeatureKind::Rest { .. } => "RestFeature", + FeatureKind::BoundaryFill { .. } => "BoundaryFillFeature", FeatureKind::SheetBase { .. } => "BaseFlangeFeature", FeatureKind::SheetContour { .. } => "ContourFlangeFeature", FeatureKind::SheetFlange { .. } => "EdgeFlangeFeature", @@ -984,6 +996,7 @@ impl FeatureKind { FeatureKind::Lip { .. } => "Groove", FeatureKind::SnapFit { .. } => "SnapFit", FeatureKind::Rest { .. } => "Rest", + FeatureKind::BoundaryFill { .. } => "BoundaryFill", FeatureKind::SheetBase { .. } => "BaseFlange", FeatureKind::SheetContour { .. } => "ContourFlange", FeatureKind::SheetFlange { .. } => "EdgeFlange", @@ -1151,7 +1164,7 @@ impl FeatureKind { v.extend(depth.iter().map(String::as_str)); } FeatureKind::ReplaceFace { target, .. } => plane_exprs(target, &mut v), - FeatureKind::Align { .. } | FeatureKind::Remove { .. } => {} + FeatureKind::Align { .. } | FeatureKind::Remove { .. } | FeatureKind::BoundaryFill { .. } => {} FeatureKind::Stitch { tolerance, .. } => v.push(tolerance), FeatureKind::Thicken { thickness, .. } => v.push(thickness), FeatureKind::SurfaceTrim { plane, .. } => plane_exprs(plane, &mut v), diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index e8580807..be3b2ccf 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -2241,6 +2241,36 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: doc.resolve_plane(vals, plane, 0)?; Ok(()) } + FeatureKind::BoundaryFill { tools, cells, operation, remove_tools } => { + if tools.is_empty() || tools.len() > 64 || cells.is_empty() || cells.len() > 1000 { + return Err(DocError::Invalid("Boundary Fill needs 1…64 tool bodies and 1…1000 cells".into())); + } + let tb: Vec = + tools.iter().map(|t| st.body(t).cloned().ok_or_else(|| DocError::Unknown(format!("body `{t}`")))).collect::>()?; + let mut made = Vec::new(); + for p in cells { + // The cell: inside the tools that hold the point, outside the others. + let inside: Vec = tb.iter().map(|b| b.mesh().contains(*p)).collect(); + let mut acc: Option = None; + for (b, _) in tb.iter().zip(&inside).filter(|(_, i)| **i) { + acc = match acc { + None => Some(b.body.clone()), + Some(a) => kernel::boolean(&a, &b.body, BoolOp::Intersect)?, + }; + } + let Some(mut cell) = acc else { + return Err(DocError::Invalid(format!("the point {p:?} is outside every tool: no enclosed cell there"))); + }; + for (b, _) in tb.iter().zip(&inside).filter(|(_, i)| !**i) { + cell = kernel::boolean(&cell, &b.body, BoolOp::Cut)?.ok_or_else(|| DocError::Invalid("a cell came out empty".into()))?; + } + made.push(cell); + } + if *remove_tools { + st.bodies.retain(|b| !tools.contains(&b.name)); + } + apply_op(st, f, made, *operation, &[]) + } FeatureKind::Split { body, plane, tool } => { let i = st.bodies.iter().position(|b| &b.name == body).ok_or_else(|| DocError::Unknown(format!("body `{body}`")))?; let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Unknown(format!("body `{body}`"))) }; diff --git a/crates/doc/src/params.rs b/crates/doc/src/params.rs index 6c95e57a..7e58da79 100644 --- a/crates/doc/src/params.rs +++ b/crates/doc/src/params.rs @@ -246,6 +246,7 @@ impl FeatureKind { | FeatureKind::SheetConvert { .. } | FeatureKind::Align { .. } | FeatureKind::Remove { .. } + | FeatureKind::BoundaryFill { .. } | FeatureKind::Loft { .. } | FeatureKind::Sweep { .. } | FeatureKind::Import { .. } diff --git a/crates/engine/src/cmd/features_more.rs b/crates/engine/src/cmd/features_more.rs index 72393120..5172d25a 100644 --- a/crates/engine/src/cmd/features_more.rs +++ b/crates/engine/src/cmd/features_more.rs @@ -11,6 +11,9 @@ use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec3}; use crate::{Result, Session}; pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("SurfaceSculpt", "Boundary Fill", boundary_fill).at("SOLID", "CREATE").icon("boundary_fill").params( + "tools: [bodies]; cells: [[x,y,z] a point inside each cell to fill (a cell: inside some tools, outside the rest)]; operation?: new|join|cut|intersect; remove_tools?: bool", + ), CommandSpec::new("solid.coil", "Coil", coil).at("SOLID", "CREATE").icon("coil").params( "diameter; two of revolutions (or turns), height, pitch; section_size; section?: circular|square; section_position?: inside|center|outside; \ base?: [x,y,z]; axis?: X|Y|Z|[x,y,z] (default Z); start_angle?; clockwise?: bool; operation?, targets?, name?, body_name?", @@ -271,3 +274,18 @@ pub(super) fn snap_point(s: &Session, kind: &str, at: Vec3) -> Option { _ => None, } } + +fn boundary_fill(s: &mut Session, p: &Value) -> Result { + let cmd = "SurfaceSculpt"; + let tools = string_list(p, "tools"); + let st = s.model.state(); + if tools.is_empty() || tools.len() > 64 || tools.iter().any(|t| st.body(t).is_none()) { + return Err(bad(cmd, "`tools` must list 1…64 bodies")); + } + let cells: Vec = p.get("cells").and_then(Value::as_array).map(|a| a.iter().filter_map(vec3).collect()).unwrap_or_default(); + if cells.is_empty() || cells.len() > 1000 { + return Err(bad(cmd, "`cells` must list 1…1000 points [x, y, z], one inside each cell")); + } + let operation = super::features::operation(p, cmd)?; + add_feature(s, p, FeatureKind::BoundaryFill { tools, cells, operation, remove_tools: bool_(p, "remove_tools").unwrap_or(false) }) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4f72b1b7..0709d741 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1808,3 +1808,20 @@ fn align_with_snap_points() { assert!((min(0) - 20.0).abs() < 1e-6 && (min(1) - 20.0).abs() < 1e-6 && (min(2) - 10.0).abs() < 1e-6, "{m}"); assert!(s.execute("solid.align", &json!({"bodies": ["Cube"], "from": {"snap": "nope", "at": [0, 0, 0]}, "to": [0, 0, 0]})).is_err()); } + +/// Boundary Fill: the lens two overlapping boxes share, and one box less the other. +#[test] +fn boundary_fill_cells() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 20, "width": 20, "height": 20, "body_name": "A"})); + run(&mut s, "solid.box", json!({"corner": [10, 0, 0], "length": 20, "width": 20, "height": 20, "body_name": "B"})); + let r = run(&mut s, "SurfaceSculpt", json!({"tools": ["A", "B"], "cells": [[15, 10, 10]], "remove_tools": true})); + let m = run(&mut s, "inspect.measure", json!({})); + assert_eq!(m["body_count"], 1, "{r} {m}"); + assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap_or(0.0), 10.0 * 20.0 * 20.0) < 1e-9); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "SurfaceSculpt", json!({"tools": ["A", "B"], "cells": [[5, 10, 10], [25, 10, 10]], "remove_tools": true})); + let m = run(&mut s, "inspect.measure", json!({})); + assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap_or(0.0), 2.0 * 10.0 * 20.0 * 20.0) < 1e-9, "{m}"); + assert!(s.execute("SurfaceSculpt", &json!({"tools": ["A"], "cells": [[100, 100, 100]]})).is_err()); +}