Sheet metal rims and holes, plastic features, lofts, raised pieces and merged walls take face attachments (#677)
Sheet metal rims and bend ends attach by name (<panel>:rim.<edge>, <flange>:rim.bend0/1), as do face profiles from sheet faces. A plain or counterbored Hole square to a panel is machined into the flat pattern. Boss, Lip, Rest and Snap Fit keep the body's history and name their own faces. Hole tools name their faces by the part they cut, and pattern copies as copies. A pattern's repeated fillets keep the body's history. Loft sides are named by their section curves. A piece Offset Face moved off its face's plane attaches by its own qualified name. Sides of a profile grown to overlap a flush body keep their sketch line's name, so walls a Join merged take a sketch (#681). Face sketch 75% -> 90%, offset plane 75% -> 90%, press-pull/extrude 64% -> 85% in the action matrix; baseline blessed. Fixes #494, fixes #495, fixes #560, fixes #561, fixes #563, fixes #564, fixes #681. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
e30f3c9ebe
commit
5ef1dcba8e
15 files changed
+2882
-1503
No files matched your search
@@ -1046,7 +1046,6 @@ fn a_through_hole_into_a_shelled_box_rim() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #560 is fixed"]
|
||||
fn a_hole_through_a_sheet_metal_base() {
|
||||
let mut c: Vec<Value> = rectangle(json!("XY"), [0., 0.], [60., 40.]).into();
|
||||
c.extend([
|
||||
@@ -1057,7 +1056,6 @@ fn a_hole_through_a_sheet_metal_base() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #561 is fixed"]
|
||||
fn sketches_on_loft_and_sweep_faces() {
|
||||
let mut loft: Vec<Value> = rectangle(json!("XY"), [-20., -20.], [20., 20.]).into();
|
||||
loft.extend([
|
||||
@@ -1096,7 +1094,6 @@ fn press_pull_on_the_pi_case_floor() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #563 is fixed"]
|
||||
fn sketches_after_a_draft_and_on_a_tapered_extrude() {
|
||||
reproduces(
|
||||
None,
|
||||
@@ -1117,7 +1114,6 @@ fn sketches_after_a_draft_and_on_a_tapered_extrude() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #564 is fixed"]
|
||||
fn a_sketch_on_the_rim_a_shell_leaves() {
|
||||
let mut c: Vec<Value> = rectangle(json!("XY"), [-35., -25.], [35., 25.]).into();
|
||||
c.extend([
|
||||
|
||||
@@ -137,13 +137,7 @@ fn body_names_saved_before_exact_history_keep_their_pieces() {
|
||||
/// ignored test below that checks its faces; a listed plane whose face attaches fails the main
|
||||
/// check until it is removed from here.
|
||||
type Planes = &'static [([f64; 3], f64)];
|
||||
const NO_ATTACHMENT_YET: [(&str, &str, Option<Planes>, u32); 5] = [
|
||||
("m0-revolve-primitives", "Body1", None, 494),
|
||||
("naming-split-pieces", "Body1", None, 494),
|
||||
("modern-plastic", "Body1", None, 494),
|
||||
("modern-sheet-metal", "Body1", None, 495),
|
||||
("issue-616-lid-ribs", "Lid", None, 628),
|
||||
];
|
||||
const NO_ATTACHMENT_YET: [(&str, &str, Option<Planes>, u32); 1] = [("issue-616-lid-ribs", "Lid", None, 628)];
|
||||
|
||||
/// The issue a face is listed under, and whether it is listed by its plane (not as every face).
|
||||
fn known_without_attachment(design: &str, body: &str, normal: [f64; 3], distance: f64) -> Option<(u32, bool)> {
|
||||
@@ -262,18 +256,6 @@ fn known_faces_attach(issue: u32) {
|
||||
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #494 is fixed"]
|
||||
fn faces_after_revolve_offset_face_and_boss_take_an_attached_sketch() {
|
||||
known_faces_attach(494);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #495 is fixed"]
|
||||
fn sheet_metal_thickness_faces_take_an_attached_sketch() {
|
||||
known_faces_attach(495);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "fails until #628 is fixed"]
|
||||
fn faces_of_a_lid_with_joined_ribs_take_an_attached_sketch() {
|
||||
|
||||
@@ -421,6 +421,40 @@ impl<'a> ExactSketchCurves<'a> {
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`find`](Self::find), else the one sketch line a generated straight segment runs along
|
||||
/// and overlaps: a profile a feature grew to overlap a flush face (its neighbours lengthened
|
||||
/// past the sketch's corners) still names its sides by the lines they were drawn on. Two
|
||||
/// candidates stay ambiguous.
|
||||
pub(crate) fn find_along(&self, generated: &Seg2) -> Option<String> {
|
||||
if let Some(id) = self.find(generated) {
|
||||
return Some(id);
|
||||
}
|
||||
let Seg2::Line { a: c, b: d } = *generated else { return None };
|
||||
let mut found: Option<Option<&str>> = None;
|
||||
for (id, linked, originals) in &self.segments {
|
||||
if *linked {
|
||||
continue;
|
||||
}
|
||||
let along = originals.iter().any(|original| {
|
||||
let Seg2::Line { a, b } = *original else { return false };
|
||||
let direction = b - a;
|
||||
let scale = direction.len().max((d - c).len()).max(1.);
|
||||
if direction.len() <= 1e-12
|
||||
|| direction.cross(c - a).abs() > 1e-10 * scale * scale
|
||||
|| direction.cross(d - a).abs() > 1e-10 * scale * scale
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let (tc, td) = ((c - a).dot(direction) / direction.len2(), (d - a).dot(direction) / direction.len2());
|
||||
tc.min(td).max(0.) < tc.max(td).min(1.) - 1e-9
|
||||
});
|
||||
if along {
|
||||
found = Some(if found.is_some() { None } else { Some(*id) });
|
||||
}
|
||||
}
|
||||
found.flatten().map(str::to_owned)
|
||||
}
|
||||
}
|
||||
|
||||
fn exact_segment_contains(original: &Seg2, generated: &Seg2) -> bool {
|
||||
@@ -581,6 +615,14 @@ pub fn lineage_frame(body: &ModelBody, plane: &solidcraft_geom::Plane) -> Option
|
||||
solidcraft_geom::Plane::new(origin, x, n.cross(x))
|
||||
}
|
||||
|
||||
/// Whether Split Face split `body`'s faces on `plane` (a split line lies on it).
|
||||
fn split_on_purpose(body: &ModelBody, plane: &solidcraft_geom::Plane) -> bool {
|
||||
let tolerance = 1e-6 * (1. + body.body.size());
|
||||
let on_plane =
|
||||
|line: &Vec<solidcraft_geom::Vec3>| !line.is_empty() && line.iter().all(|p| (*p - plane.origin).dot(plane.normal()).abs() <= tolerance);
|
||||
body.body.split_lines().is_some_and(|lines| lines.iter().any(on_plane))
|
||||
}
|
||||
|
||||
/// Whether two exact planes are one oriented plane (pieces of one face).
|
||||
fn same_oriented_plane(a: &solidcraft_geom::Plane, b: &solidcraft_geom::Plane, scale: f64) -> bool {
|
||||
a.normal().dot(b.normal()) >= 1. - 1e-8 && (b.origin - a.origin).dot(a.normal()).abs() <= 1e-7 * (1. + scale)
|
||||
@@ -599,7 +641,9 @@ impl Document {
|
||||
|
||||
/// A target for flat face `index` of `body` by its exact history name
|
||||
/// ([`FaceCertificate::Lineage`]), when that name follows exact history, names a feature of
|
||||
/// the design and finds this face (with its other pieces, all on one oriented plane).
|
||||
/// the design and finds this face (with its other pieces, all on one oriented plane). Where
|
||||
/// the face's pieces no longer share a plane (Offset Face moved one of them), the piece by
|
||||
/// its own name: the face's name qualified by the faces around it (`F2:top#[F2:side:l4,…]`).
|
||||
pub(crate) fn lineage_face_target(
|
||||
&self,
|
||||
body: &ModelBody,
|
||||
@@ -610,17 +654,24 @@ impl Document {
|
||||
if !names.verified(index) {
|
||||
return None;
|
||||
}
|
||||
let name = crate::naming::strip_piece(names.faces.get(index)?).to_string();
|
||||
let origin = name.strip_prefix('F')?.split_once(':')?.0.parse::<u64>().ok()?;
|
||||
let full = names.faces.get(index)?;
|
||||
let stem = crate::naming::strip_piece(full);
|
||||
let origin = stem.strip_prefix('F')?.split_once(':')?.0.parse::<u64>().ok()?;
|
||||
let inverse = crate::mat_inverse(&body.source_transform)?;
|
||||
let target = crate::config::ConfigFaceTarget {
|
||||
body: body.definition_name().into(),
|
||||
origin_feature: origin,
|
||||
name,
|
||||
point: crate::apply_point(&inverse, point),
|
||||
certificate: Some(FaceCertificate::Lineage),
|
||||
};
|
||||
self.configuration_face_indices(body, &target).ok()?.contains(&index).then_some(target)
|
||||
let mut candidates = vec![stem];
|
||||
if full != stem {
|
||||
candidates.push(full);
|
||||
}
|
||||
candidates.into_iter().find_map(|name| {
|
||||
let target = crate::config::ConfigFaceTarget {
|
||||
body: body.definition_name().into(),
|
||||
origin_feature: origin,
|
||||
name: name.to_string(),
|
||||
point: crate::apply_point(&inverse, point),
|
||||
certificate: Some(FaceCertificate::Lineage),
|
||||
};
|
||||
self.configuration_face_indices(body, &target).ok()?.contains(&index).then_some(target)
|
||||
})
|
||||
}
|
||||
|
||||
/// The faces of `body` an exact history name finds: those named `name` or a piece of it, else
|
||||
@@ -628,8 +679,20 @@ impl Document {
|
||||
/// follow exact history and lie on one oriented plane.
|
||||
pub(crate) fn lineage_face_indices(&self, body: &ModelBody, name: &str) -> Result<Vec<usize>> {
|
||||
let names = crate::naming::names(body);
|
||||
let mut indices: Vec<usize> =
|
||||
names.faces.iter().enumerate().filter(|(_, face)| crate::naming::strip_piece(face) == name).map(|(index, _)| index).collect();
|
||||
// `face` is `name` itself or a piece of it (a piece of a piece, …).
|
||||
let piece_of = |face: &str| {
|
||||
let mut n = face;
|
||||
loop {
|
||||
if n == name {
|
||||
return true;
|
||||
}
|
||||
match crate::piece_names::parent_piece(n) {
|
||||
Some(parent) => n = parent,
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut indices: Vec<usize> = names.faces.iter().enumerate().filter(|(_, face)| piece_of(face)).map(|(index, _)| index).collect();
|
||||
if indices.is_empty() {
|
||||
indices =
|
||||
names.info.iter().enumerate().filter(|(_, info)| info.aliases.iter().any(|alias| alias == name)).map(|(index, _)| index).collect();
|
||||
@@ -638,6 +701,16 @@ impl Document {
|
||||
return Err(DocError::Unknown(format!("face `{name}` on `{}` no longer exists", body.name)));
|
||||
}
|
||||
self.coplanar_pieces(body, &indices, |index| names.verified(index))?;
|
||||
// A piece by its own name stands for itself, which a piece of a face split on purpose
|
||||
// (Split Face) never does: that needs a fixed plane.
|
||||
if crate::naming::strip_piece(name) != name
|
||||
&& let Some(plane) = indices.first().and_then(|index| body.body.planar_face(*index))
|
||||
&& split_on_purpose(body, &plane)
|
||||
{
|
||||
return Err(DocError::Invalid(
|
||||
"this face was split on purpose (Split Face): pick an exact face piece's own plane with fixed:true".into(),
|
||||
));
|
||||
}
|
||||
Ok(indices)
|
||||
}
|
||||
|
||||
@@ -658,16 +731,12 @@ impl Document {
|
||||
Some(_) => return Err(DocError::Invalid("attached face pieces no longer share one oriented plane".into())),
|
||||
}
|
||||
}
|
||||
if let (Some(plane), true) = (first, indices.len() > 1) {
|
||||
let tolerance = 1e-6 * (1. + scale);
|
||||
let on_plane = |line: &Vec<solidcraft_geom::Vec3>| {
|
||||
!line.is_empty() && line.iter().all(|p| (*p - plane.origin).dot(plane.normal()).abs() <= tolerance)
|
||||
};
|
||||
if body.body.split_lines().is_some_and(|lines| lines.iter().any(on_plane)) {
|
||||
return Err(DocError::Invalid(
|
||||
"this face was split on purpose (Split Face): pick an exact face piece's own plane with fixed:true".into(),
|
||||
));
|
||||
}
|
||||
if let (Some(plane), true) = (first, indices.len() > 1)
|
||||
&& split_on_purpose(body, &plane)
|
||||
{
|
||||
return Err(DocError::Invalid(
|
||||
"this face was split on purpose (Split Face): pick an exact face piece's own plane with fixed:true".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -124,7 +124,12 @@ impl Document {
|
||||
target.validate()?;
|
||||
let prefix = format!("F{}:", target.origin_feature);
|
||||
let role = target.name.strip_prefix(&prefix).ok_or_else(|| DocError::Invalid("face lineage does not match its feature".into()))?;
|
||||
if crate::naming::strip_piece(&target.name) != target.name {
|
||||
// An exact history name may name a piece by the faces around it (`F2:top#[F2:side:l4,…]`),
|
||||
// never by a number (an older design's piece, or a tie the faces around it left).
|
||||
let numbered =
|
||||
target.name.match_indices('#').any(|(i, _)| target.name.get(i + 1..).is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit())));
|
||||
let lineage_piece = matches!(target.certificate, Some(super::FaceCertificate::Lineage)) && !numbered;
|
||||
if crate::naming::strip_piece(&target.name) != target.name && !lineage_piece {
|
||||
return Err(DocError::Invalid("configure a face lineage, not a numbered split piece".into()));
|
||||
}
|
||||
let feature = self.feature(target.origin_feature).ok_or_else(|| DocError::Unknown("face origin feature is gone".into()))?;
|
||||
|
||||
+117
-35
@@ -1872,6 +1872,63 @@ fn hole_spots_raw(st: &ModelState, f: &Feature) -> Result<(Vec<Vec3>, Vec3)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The cutting tools of a hole feature at every spot, each with its exact geometry when it is a
|
||||
/// straight cylinder (see [`hole_tools_and_prisms`]).
|
||||
fn hole_feature_tools(vals: &BTreeMap<String, Value>, st: &ModelState, f: &Feature) -> Result<Vec<(Traced, Option<more::PrimitivePrism>)>> {
|
||||
let FeatureKind::Hole { diameter, depth, hole, to, .. } = &f.kind else { return Err(DocError::Invalid("not a hole".into())) };
|
||||
let (spots, dir) = hole_spots(st, f)?;
|
||||
if spots.is_empty() || spots.len() > 10_000 {
|
||||
return Err(DocError::Invalid("a hole needs 1…10000 points".into()));
|
||||
}
|
||||
let mut tools = Vec::new();
|
||||
for at in spots {
|
||||
let depth = hole_depth_to(st, &at, dir, to, depth)?;
|
||||
tools.extend(hole_tools_and_prisms(vals, st, &at, dir, diameter, &depth, hole)?);
|
||||
}
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
/// A hole drilled square into sheet metal whose tools are straight cylinders (a plain hole, a
|
||||
/// counterbore) is machined like the equivalent extrude cut, so the sheet keeps it as a cut-out
|
||||
/// or pocket of its flat pattern (#560). Returns the sheet bodies it machined; none, with the
|
||||
/// model as it was, when a tool that meets a sheet can't be kept that way (the ordinary cut then
|
||||
/// refuses it).
|
||||
fn sheet_hole_cut(st: &mut ModelState, f: &Feature, tools: &[(Traced, Option<more::PrimitivePrism>)]) -> Result<Vec<String>> {
|
||||
if st.sheets.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let saved = (st.sheets.clone(), st.bodies.clone());
|
||||
let mut handled: Vec<String> = Vec::new();
|
||||
for (tool, prism) in tools {
|
||||
let tool = &tool.body;
|
||||
let done = match prism {
|
||||
Some(prism) => more::sheet_primitive_cut(f, st, tool, *prism, Operation::Cut)?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
if done.is_empty() {
|
||||
let mut meets = false;
|
||||
for sheet in &st.sheets {
|
||||
if let Some(mb) = st.body(&sheet.body)
|
||||
&& kernel::boolean(&mb.body, tool, BoolOp::Intersect)?.is_some()
|
||||
{
|
||||
meets = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if meets {
|
||||
(st.sheets, st.bodies) = saved;
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
}
|
||||
for name in done {
|
||||
if !handled.contains(&name) {
|
||||
handled.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(handled)
|
||||
}
|
||||
|
||||
/// The cutting tools of one hole at `position`, drilling along `dir`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn hole_tools(
|
||||
@@ -1882,7 +1939,24 @@ fn hole_tools(
|
||||
diameter: &str,
|
||||
depth: &Option<String>,
|
||||
hole: &HoleKind,
|
||||
) -> Result<Vec<Body>> {
|
||||
) -> Result<Vec<Traced>> {
|
||||
Ok(hole_tools_and_prisms(vals, st, position, dir, diameter, depth, hole)?.into_iter().map(|(tool, _)| tool).collect())
|
||||
}
|
||||
|
||||
/// [`hole_tools`], each with its exact geometry when it is a straight cylinder along the axis
|
||||
/// (a plain hole, a counterbore, a drill point left in the air past a through hole), so sheet
|
||||
/// metal can keep it as a flat cut-out or pocket. Each tool's faces are named by the part of the
|
||||
/// hole it cuts (`bore`, `cbore`, `csink`: `F7:cbore:base` is a counterbore's floor).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn hole_tools_and_prisms(
|
||||
vals: &BTreeMap<String, Value>,
|
||||
st: &ModelState,
|
||||
position: &Vec3,
|
||||
dir: Vec3,
|
||||
diameter: &str,
|
||||
depth: &Option<String>,
|
||||
hole: &HoleKind,
|
||||
) -> Result<Vec<(Traced, Option<more::PrimitivePrism>)>> {
|
||||
let r = val(vals, diameter, Kind::Length)? / 2.0;
|
||||
if !(r > 1e-6) {
|
||||
return Err(DocError::Invalid("hole diameter must be positive".into()));
|
||||
@@ -1905,12 +1979,15 @@ fn hole_tools(
|
||||
seam.set(seam.get() + 0.613);
|
||||
Region2 { outer: solidcraft_geom::Loop2::circle_from(Vec2::ZERO, rad, seam.get()), holes: vec![] }
|
||||
};
|
||||
let cyl = |rad: f64, lo: f64, hi: f64| -> Result<Body> {
|
||||
kernel::extrude(&plane, &[disc(rad)], lo, hi)?.pop().ok_or_else(|| DocError::Invalid("hole tool".into()))
|
||||
let cyl = |rad: f64, lo: f64, hi: f64, part: &str| -> Result<(Traced, Option<more::PrimitivePrism>)> {
|
||||
let tool = kernel::extrude(&plane, &[disc(rad)], lo, hi)?.pop().ok_or_else(|| DocError::Invalid("hole tool".into()))?;
|
||||
let axis = plane.normal();
|
||||
let prism = more::PrimitivePrism::Cylinder { base: *position + axis * lo, axis, radius: rad, height: hi - lo };
|
||||
Ok((crate::lineage::part_tool(tool, part), Some(prism)))
|
||||
};
|
||||
let mut tools = Vec::new();
|
||||
match hole {
|
||||
HoleKind::Simple => tools.push(cyl(r, bottom, top)?),
|
||||
HoleKind::Simple => tools.push(cyl(r, bottom, top, "bore")?),
|
||||
HoleKind::Drilled { tip_angle } => {
|
||||
let half = val(vals, tip_angle, Kind::Angle)? / 2.0;
|
||||
// A hole exactly as deep as the material: the point is in the air past the exit
|
||||
@@ -1927,9 +2004,13 @@ fn hole_tools(
|
||||
let rp = Plane::new(*position, side, up).ok_or_else(|| DocError::Invalid("hole axis".into()))?;
|
||||
let pts = [Vec2::new(0.0, top), Vec2::new(r, top), Vec2::new(r, bottom), Vec2::new(0.0, bottom - tip)];
|
||||
let region = Region2 { outer: solidcraft_geom::Loop2::polygon(&pts), holes: vec![] };
|
||||
tools.extend(kernel::revolve(&rp, &[region], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)?);
|
||||
tools.extend(
|
||||
kernel::revolve(&rp, &[region], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)?
|
||||
.into_iter()
|
||||
.map(|tool| (crate::lineage::part_tool(tool, "bore"), None)),
|
||||
);
|
||||
} else {
|
||||
tools.push(cyl(r, bottom, top)?);
|
||||
tools.push(cyl(r, bottom, top, "bore")?);
|
||||
}
|
||||
}
|
||||
HoleKind::Counterbore { cb_diameter, cb_depth } => {
|
||||
@@ -1938,8 +2019,8 @@ fn hole_tools(
|
||||
return Err(DocError::Invalid("the counterbore must be wider than the hole and shallower than it".into()));
|
||||
}
|
||||
// Wide and shallow first: the narrow hole then crosses the pocket floor.
|
||||
tools.push(cyl(rc, -dc, top)?);
|
||||
tools.push(cyl(r, bottom, top)?);
|
||||
tools.push(cyl(rc, -dc, top, "cbore")?);
|
||||
tools.push(cyl(r, bottom, top, "bore")?);
|
||||
}
|
||||
HoleKind::Countersink { cs_diameter, cs_angle } => {
|
||||
let (rc, half) = (val(vals, cs_diameter, Kind::Length)? / 2.0, val(vals, cs_angle, Kind::Angle)? / 2.0);
|
||||
@@ -1951,8 +2032,8 @@ fn hole_tools(
|
||||
let r_end = r * 0.9;
|
||||
let len = (r_top - r_end) / half.tan();
|
||||
let top_plane = plane.offset(top);
|
||||
tools.push(kernel::extrude_tapered(&top_plane, &disc(r_top), len, -1.0, -half)?);
|
||||
tools.push(cyl(r, bottom, top)?);
|
||||
tools.push((crate::lineage::part_tool(kernel::extrude_tapered(&top_plane, &disc(r_top), len, -1.0, -half)?, "csink"), None));
|
||||
tools.push(cyl(r, bottom, top, "bore")?);
|
||||
}
|
||||
}
|
||||
Ok(tools)
|
||||
@@ -2104,10 +2185,6 @@ fn centered_extrude(plane: &Plane, region: &Region2, start: f64, end: f64, sketc
|
||||
}
|
||||
|
||||
/// The tool bodies a feature adds or removes (extrude, revolve, primitives), in the current state.
|
||||
fn feature_tools(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &ModelState) -> Result<Vec<Body>> {
|
||||
Ok(feature_tools_with_constructor(doc, vals, f, st, &mut None)?.into_iter().map(|t| t.body).collect())
|
||||
}
|
||||
|
||||
fn feature_tools_with_constructor(
|
||||
doc: &Document,
|
||||
vals: &BTreeMap<String, Value>,
|
||||
@@ -2417,7 +2494,8 @@ fn feature_tools_with_constructor(
|
||||
let id = pt.point.as_deref().unwrap_or_default();
|
||||
let q = ps.sketch.points.iter().find(|p| p.id == id).ok_or_else(|| DocError::Unknown(format!("point `{id}` in {}", ps.name)))?;
|
||||
let body = kernel::loft_to_point(&ss.plane, &r.outer, ps.plane.to_world(q.pos))?;
|
||||
Ok(vec![crate::lineage::ProfileRoles::swept(None, None).tool(body, None)])
|
||||
// Its sides are named by the profile's curves, its base `start`.
|
||||
Ok(vec![crate::lineage::ProfileRoles::swept(Some(ss), Some(r)).tool(body, None)])
|
||||
}
|
||||
FeatureKind::Coil { base, axis, diameter, pitch, turns, section_size, section, position, start_angle, clockwise, .. } => {
|
||||
let base = &point(vals, base)?;
|
||||
@@ -2460,6 +2538,7 @@ fn feature_tools_with_constructor(
|
||||
}
|
||||
FeatureKind::Loft { sections, .. } => {
|
||||
let mut secs = Vec::new();
|
||||
let mut sketches = Vec::new();
|
||||
for sec in sections {
|
||||
let ss = st
|
||||
.sketch(sec.sketch)
|
||||
@@ -2467,11 +2546,13 @@ fn feature_tools_with_constructor(
|
||||
let regions = solidcraft_sketch::merge_regions(&select_profiles(ss, &sec.profiles)?);
|
||||
let [r] = ®ions[..] else { return Err(DocError::Invalid("each loft section must be one profile".into())) };
|
||||
secs.push((ss.plane, r.outer.clone()));
|
||||
sketches.push(ss);
|
||||
}
|
||||
// Its caps are the first and last sections; which section's curve a side follows is
|
||||
// not recorded, so the sides are named from geometry.
|
||||
// Its caps are the first and last sections, its sides named by the curves they run
|
||||
// between.
|
||||
let body = kernel::loft(&secs)?;
|
||||
Ok(vec![crate::lineage::ProfileRoles::swept(None, None).tool(body, None)])
|
||||
let roles = crate::lineage::SectionRoles { sections: sketches.into_iter().zip(secs.iter().map(|(_, lp)| lp)).collect() };
|
||||
Ok(vec![roles.tool(body)])
|
||||
}
|
||||
FeatureKind::Sweep { sketch, profiles, path_sketch, path, .. } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?;
|
||||
@@ -2536,18 +2617,7 @@ fn feature_tools_with_constructor(
|
||||
FeatureKind::Torus { center, major, minor, .. } => {
|
||||
Ok(unnamed(vec![kernel::torus(point(vals, center)?, val(vals, major, Kind::Length)?, val(vals, minor, Kind::Length)?)?]))
|
||||
}
|
||||
FeatureKind::Hole { diameter, depth, hole, to, .. } => {
|
||||
let (spots, dir) = hole_spots(st, f)?;
|
||||
if spots.is_empty() || spots.len() > 10_000 {
|
||||
return Err(DocError::Invalid("a hole needs 1…10000 points".into()));
|
||||
}
|
||||
let mut tools = Vec::new();
|
||||
for at in spots {
|
||||
let depth = hole_depth_to(st, &at, dir, to, depth)?;
|
||||
tools.extend(hole_tools(vals, st, &at, dir, diameter, &depth, hole)?);
|
||||
}
|
||||
Ok(unnamed(tools))
|
||||
}
|
||||
FeatureKind::Hole { .. } => Ok(hole_feature_tools(vals, st, f)?.into_iter().map(|(tool, _)| tool).collect()),
|
||||
_ => Err(DocError::Invalid(format!("{} cannot be patterned or mirrored yet", f.name))),
|
||||
}
|
||||
}
|
||||
@@ -2997,7 +3067,8 @@ fn replay(
|
||||
},
|
||||
_ => kernel::fillet(&mb.body, &moved, r)?,
|
||||
};
|
||||
let nb = Traced::step(nb, &[&mb.traced()]);
|
||||
// A copy blends edges the pattern's own copies made: named after the body so far.
|
||||
let nb = mb.traced().then(nb);
|
||||
if let Some(slot) = st.bodies.get_mut(ti) {
|
||||
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
|
||||
}
|
||||
@@ -3011,13 +3082,18 @@ fn replay(
|
||||
// a blind hole's depth counts from the face at the copy, not from the bottom of the
|
||||
// original hole under the original spot.
|
||||
let (spots, dir) = hole_spots_raw(st, src)?;
|
||||
for m in mats {
|
||||
for (k, m) in mats.iter().enumerate() {
|
||||
let d = crate::apply_vector(m, dir).normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?;
|
||||
for p in &spots {
|
||||
let at = hole_on_surface(st, crate::apply_point(m, *p), d);
|
||||
let to = to.map(|t| crate::apply_point(m, t));
|
||||
let depth = hole_depth_to(st, &at, d, &to, depth)?;
|
||||
copies.extend(hole_tools(vals, st, &at, d, diameter, &depth, hole)?.into_iter().map(Traced::unnamed));
|
||||
// Each copy's faces are named as the hole's, with the instance.
|
||||
copies.extend(
|
||||
hole_tools(vals, st, &at, d, diameter, &depth, hole)?
|
||||
.into_iter()
|
||||
.map(|t| Traced { lineage: t.lineage.map(|l| l.copied(src.id, k + 1)), body: t.body }),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -3419,8 +3495,14 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
}
|
||||
FeatureKind::Hole { thread, diameter, .. } => {
|
||||
let (spots, dir) = hole_spots(st, f)?;
|
||||
let tools = feature_tools(doc, vals, f, st)?;
|
||||
apply_op(st, f, tools, Operation::Cut, &[])?;
|
||||
let tools = hole_feature_tools(vals, st, f)?;
|
||||
let handled = sheet_hole_cut(st, f, &tools)?;
|
||||
let tools: Vec<Traced> = tools.into_iter().map(|(tool, _)| tool).collect();
|
||||
if handled.is_empty() {
|
||||
apply_traced(st, f, tools, Operation::Cut, &[])?;
|
||||
} else {
|
||||
apply_beside_sheet_cut(st, f, tools, Operation::Cut, &[], &handled)?;
|
||||
}
|
||||
if let Some(d) = thread {
|
||||
// A point on each hole's wall, a little below the entry.
|
||||
let r = val(vals, diameter, Kind::Length)? / 2.0;
|
||||
|
||||
+18
-13
@@ -718,6 +718,7 @@ pub(super) fn sheet_primitive_cut(f: &Feature, st: &mut ModelState, tool: &Body,
|
||||
}
|
||||
|
||||
/// A primitive tool's exact geometry, for [`sheet_primitive_cut`].
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) enum PrimitivePrism {
|
||||
/// An axis-aligned box between two corners.
|
||||
Box { lo: Vec3, hi: Vec3 },
|
||||
@@ -1117,11 +1118,12 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
let base = Plane::new(*position - z * delta, x, z.cross(x)).ok_or_else(|| DocError::Invalid("boss frame".into()))?;
|
||||
let r_in = r0 + delta * dr.tan();
|
||||
let disc = Region2 { outer: Loop2::circle(Vec2::ZERO, r_in), holes: Vec::new() };
|
||||
let mut tools = if dr.abs() > 1e-12 {
|
||||
let post = if dr.abs() > 1e-12 {
|
||||
vec![kernel::extrude_tapered(&base, &disc, h + delta, 1.0, -dr)?]
|
||||
} else {
|
||||
kernel::extrude(&base, &[disc], 0.0, h + delta)?
|
||||
};
|
||||
let mut tools: Vec<Traced> = post.into_iter().map(|b| crate::lineage::part_tool(b, "post")).collect();
|
||||
if let Some(n) = ribs {
|
||||
let n = val(vals, n, Kind::Unitless)?.round();
|
||||
if !(0.0..=64.0).contains(&n) {
|
||||
@@ -1156,19 +1158,19 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
]),
|
||||
holes: Vec::new(),
|
||||
};
|
||||
tools.extend(kernel::extrude(&rp, &[rect], -delta, hr)?);
|
||||
tools.extend(kernel::extrude(&rp, &[rect], -delta, hr)?.into_iter().map(|b| crate::lineage::part_tool(b, &format!("rib{k}"))));
|
||||
}
|
||||
}
|
||||
apply_op(st, f, tools, Operation::Join, std::slice::from_ref(&target))?;
|
||||
apply_traced(st, f, tools, Operation::Join, std::slice::from_ref(&target))?;
|
||||
// The root fillet: rounds the circle where the post meets the face.
|
||||
if rf > 1e-9 {
|
||||
let i = st.bodies.iter().position(|b| b.name == target).ok_or_else(|| DocError::Invalid("boss body".into()))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("boss body".into())) };
|
||||
let mut warn = None;
|
||||
let edges = resolve_edges(&mb.body, &[*position + x * r0], &[], &mut warn)?;
|
||||
let nb = kernel::fillet(&mb.body, &edges, rf)?;
|
||||
let nb = mb.traced().then(kernel::fillet(&mb.body, &edges, rf)?);
|
||||
if let Some(slot) = st.bodies.get_mut(i) {
|
||||
*slot = ModelBody::new(mb.name, nb, mb.feature);
|
||||
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
|
||||
}
|
||||
}
|
||||
if let Some(hd) = hole_diameter {
|
||||
@@ -1182,7 +1184,8 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
}
|
||||
let hp = Plane::new(*position, x, z.cross(x)).ok_or_else(|| DocError::Invalid("hole frame".into()))?;
|
||||
let tool = kernel::extrude(&hp, &[Region2 { outer: Loop2::circle(Vec2::ZERO, rh), holes: Vec::new() }], h - depth, h + delta)?;
|
||||
apply_op(st, f, tool, Operation::Cut, std::slice::from_ref(&target))?;
|
||||
let tool = tool.into_iter().map(|b| crate::lineage::part_tool(b, "hole")).collect();
|
||||
apply_traced(st, f, tool, Operation::Cut, std::slice::from_ref(&target))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1215,7 +1218,7 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
&& let Ok(b) = kernel::raise_band(&mb.body, *face, w, h, *outside)
|
||||
&& let Some(slot) = st.bodies.iter_mut().find(|x| x.name == target)
|
||||
{
|
||||
*slot = ModelBody::new(target.clone(), b, slot.feature);
|
||||
*slot = ModelBody::traced_new(target.clone(), Traced::step(b, &[&mb.traced()]), slot.feature);
|
||||
return Ok(());
|
||||
}
|
||||
// The band along the chosen edge, inside the rim face. A groove's cutter reaches past
|
||||
@@ -1234,11 +1237,11 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
};
|
||||
let delta = (h * 0.05).clamp(1e-3, 0.5);
|
||||
if *groove {
|
||||
let tool = kernel::extrude(&plane, &[band], -(h + g), delta)?;
|
||||
apply_op(st, f, tool, Operation::Cut, std::slice::from_ref(&target))
|
||||
let tool = kernel::extrude(&plane, &[band], -(h + g), delta)?.into_iter().map(|b| crate::lineage::part_tool(b, "groove")).collect();
|
||||
apply_traced(st, f, tool, Operation::Cut, std::slice::from_ref(&target))
|
||||
} else {
|
||||
let tool = kernel::extrude(&plane, &[band], -delta, h)?;
|
||||
apply_op(st, f, tool, Operation::Join, std::slice::from_ref(&target))
|
||||
let tool = kernel::extrude(&plane, &[band], -delta, h)?.into_iter().map(|b| crate::lineage::part_tool(b, "lip")).collect();
|
||||
apply_traced(st, f, tool, Operation::Join, std::slice::from_ref(&target))
|
||||
}
|
||||
}
|
||||
FeatureKind::SnapFit { position, direction, hook, length, thickness, width, catch_depth, catch_length, body } => {
|
||||
@@ -1261,7 +1264,8 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
let sgn = if pl.normal().dot(y) >= 0.0 { 1.0 } else { -1.0 };
|
||||
let (lo, hi) = if sgn > 0.0 { (0.0, w) } else { (-w, 0.0) };
|
||||
let tool = kernel::extrude(&pl, &[Region2 { outer: lp, holes: Vec::new() }], lo, hi)?;
|
||||
apply_op(st, f, tool, Operation::Join, std::slice::from_ref(&target))
|
||||
let tool = tool.into_iter().map(|b| crate::lineage::part_tool(b, "arm")).collect();
|
||||
apply_traced(st, f, tool, Operation::Join, std::slice::from_ref(&target))
|
||||
}
|
||||
FeatureKind::Rest { position, direction, along, width, length, height, draft, thickness, body } => {
|
||||
let target = body_under(st, body, *position)?;
|
||||
@@ -1310,7 +1314,8 @@ pub(super) fn plastic_eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &F
|
||||
} else {
|
||||
kernel::extrude(&base, &[region], 0.0, h + delta)?
|
||||
};
|
||||
apply_op(st, f, tool, Operation::Join, std::slice::from_ref(&target))
|
||||
let tool = tool.into_iter().map(|b| crate::lineage::part_tool(b, "rest")).collect();
|
||||
apply_traced(st, f, tool, Operation::Join, std::slice::from_ref(&target))
|
||||
}
|
||||
_ => Err(DocError::Invalid(format!("{} is not a plastic feature", f.name))),
|
||||
}
|
||||
|
||||
@@ -72,7 +72,12 @@
|
||||
//! Sketch face anchors can name a flat face by its exact history name (a `lineage`
|
||||
//! certificate: the target's `name`, e.g. a cut wall `F5:side:l1` or a pattern copy
|
||||
//! `F4:[email protected]`), where no construction certificate names it alone. Anchors with
|
||||
//! construction certificates resolve as before.
|
||||
//! construction certificates resolve as before. A `lineage` name can also be a piece's own,
|
||||
//! qualified by the faces around it (`F2:top#[F2:side:l4,F4:side:l4]`), where Offset Face
|
||||
//! moved one piece of a face off the others' plane; never a numbered piece. Sheet face names
|
||||
//! can also name a rim, the sheet's thickness along a panel's straight edge
|
||||
//! (`sheet:<body>:<panel>:rim.<edge>`, the edge a base sketch line's id, `edge<k>`, or a
|
||||
//! flange's `far`, `side0` or `side1`), or a flat end of a bend (`rim.bend0`, `rim.bend1`).
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
|
||||
@@ -160,6 +160,27 @@ impl Traced {
|
||||
let lineage = lineages.and_then(|l| Lineage::step(&result, &l));
|
||||
Traced { body: result, lineage }
|
||||
}
|
||||
|
||||
/// `result`, made by one kernel operation from this body alone. Where that operation's
|
||||
/// history can't be composed with this body's (it blends an edge the same feature made: a
|
||||
/// boss's root fillet), this body is a stage of the feature, its faces and edges named as a
|
||||
/// whole first.
|
||||
pub fn then(&self, result: Body) -> Traced {
|
||||
let step = Traced::step(result.clone(), &[self]);
|
||||
if step.lineage.is_some() {
|
||||
return step;
|
||||
}
|
||||
let lineage = self.lineage.as_ref().and_then(|l| {
|
||||
let history = result.history()?.clone();
|
||||
if history.operands != 1 {
|
||||
return None;
|
||||
}
|
||||
let body = crate::ModelBody::new(String::new(), self.body.clone(), 0);
|
||||
let stage = Arc::new(Operand::Stage(Box::new(Stage { body, lineage: l.clone(), names: OnceLock::new() })));
|
||||
Some(Lineage { history, operands: vec![stage] })
|
||||
});
|
||||
Traced { body: result, lineage }
|
||||
}
|
||||
}
|
||||
|
||||
/// How the bodies a feature builds from a profile name their faces: what each profile segment's
|
||||
@@ -186,7 +207,7 @@ impl<'a> ProfileRoles<'a> {
|
||||
let segments: Vec<&solidcraft_geom::Seg2> =
|
||||
self.region.map(|r| std::iter::once(&r.outer).chain(&r.holes).flat_map(|l| &l.segs).collect()).unwrap_or_default();
|
||||
let curves = self.sketch.map(crate::config::ExactSketchCurves::new);
|
||||
let curve = |segment: usize| -> Option<String> { curves.as_ref()?.find(segments.get(segment)?).map(|id| format!("side:{id}")) };
|
||||
let curve = |segment: usize| -> Option<String> { curves.as_ref()?.find_along(segments.get(segment)?).map(|id| format!("side:{id}")) };
|
||||
let roles = history
|
||||
.faces
|
||||
.iter()
|
||||
@@ -228,6 +249,64 @@ impl<'a> ProfileRoles<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// How a loft names its faces: each side by the sketch curves of the sections it runs between
|
||||
/// (`side:<curve>` for the first section's, `side:s<k>.<curve>` for section `k`'s), named after
|
||||
/// the section with the most segments (the one each side spans exactly one segment of) and
|
||||
/// answering to the others; its caps `start` and `end`.
|
||||
pub(crate) struct SectionRoles<'a> {
|
||||
/// Each section's sketch and the loop lofted through it, in the order given.
|
||||
pub sections: Vec<(&'a SolvedSketch, &'a solidcraft_geom::Loop2)>,
|
||||
}
|
||||
|
||||
impl SectionRoles<'_> {
|
||||
/// The loft `body`, its faces named by their roles where its history gives them.
|
||||
pub fn tool(&self, body: Body) -> Traced {
|
||||
let roles = body.history().filter(|h| h.operands == 0).map(|h| self.of(h));
|
||||
Traced::made(body, roles)
|
||||
}
|
||||
|
||||
fn of(&self, history: &History) -> Vec<Vec<String>> {
|
||||
// Each profile segment (numbered through the sections in order): its section and role.
|
||||
let mut segments: Vec<(usize, Option<String>)> = Vec::new();
|
||||
for (k, (sketch, lp)) in self.sections.iter().enumerate() {
|
||||
let curves = crate::config::ExactSketchCurves::new(sketch);
|
||||
for seg in &lp.segs {
|
||||
let role = curves.find_along(seg).map(|id| if k == 0 { format!("side:{id}") } else { format!("side:s{k}.{id}") });
|
||||
segments.push((k, role));
|
||||
}
|
||||
}
|
||||
let lead = self.sections.iter().enumerate().max_by_key(|(k, (_, lp))| (lp.segs.len(), std::cmp::Reverse(*k))).map_or(0, |(k, _)| k);
|
||||
history
|
||||
.faces
|
||||
.iter()
|
||||
.map(|face| {
|
||||
let mut roles: Vec<(bool, usize, String)> = Vec::new();
|
||||
for origin in &face.origins {
|
||||
let (k, role) = match *origin {
|
||||
FaceOrigin::Cap { end } => (0, Some(if end { "end" } else { "start" }.to_string())),
|
||||
FaceOrigin::Profile { segment } => match segments.get(segment) {
|
||||
Some((k, role)) => (*k, role.clone()),
|
||||
None => (0, None),
|
||||
},
|
||||
_ => (0, None),
|
||||
};
|
||||
// One unknown origin leaves the face unknown: never a partial guess.
|
||||
let Some(role) = role else { return Vec::new() };
|
||||
roles.push((k != lead, k, role));
|
||||
}
|
||||
roles.sort();
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for (.., role) in roles {
|
||||
if !out.contains(&role) {
|
||||
out.push(role);
|
||||
}
|
||||
}
|
||||
out
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A box primitive, its faces named by the side they face (`+x`, `-z`, …), as its construction
|
||||
/// records them.
|
||||
pub(crate) fn box_tool(body: Body) -> Traced {
|
||||
@@ -260,6 +339,23 @@ pub(crate) fn cylinder_tool(body: Body) -> Traced {
|
||||
Traced::made(body, roles)
|
||||
}
|
||||
|
||||
/// A tool a feature builds without a sketch (a boss's post, a rib, a lip's band), its faces named
|
||||
/// by the part it makes: `<part>:top` (its end cap), `<part>:base` (its start cap) and
|
||||
/// `<part>:side:<k>` (swept from segment `k` of its profile), as its construction records them.
|
||||
pub(crate) fn part_tool(body: Body, part: &str) -> Traced {
|
||||
let roles = body.history().filter(|h| h.operands == 0).map(|h| {
|
||||
h.faces
|
||||
.iter()
|
||||
.map(|f| match f.origins.as_slice() {
|
||||
[FaceOrigin::Cap { end }] => vec![format!("{part}:{}", if *end { "top" } else { "base" })],
|
||||
[FaceOrigin::Profile { segment }] => vec![format!("{part}:side:{segment}")],
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
Traced::made(body, roles)
|
||||
}
|
||||
|
||||
/// Faces named by their place in a body built as a whole (an imported part's faces in the
|
||||
/// file's order): `face:<k>`.
|
||||
pub(crate) fn numbered_tool(body: Body) -> Traced {
|
||||
|
||||
+165
-2
@@ -2708,8 +2708,9 @@ impl SheetBody {
|
||||
}
|
||||
|
||||
/// The flat face of the sheet that a point lies on (sheet coordinates, within `tol`, which
|
||||
/// stays below a quarter of the thickness): its persistent name (`<panel>:top` or
|
||||
/// `<panel>:bottom`) and its plane (see [`panel_face`](Self::panel_face)).
|
||||
/// stays below a quarter of the thickness): its persistent name (`<panel>:top`,
|
||||
/// `<panel>:bottom`, or a rim's `<panel>:rim.<edge>`) and its plane (see
|
||||
/// [`panel_face`](Self::panel_face)).
|
||||
pub fn panel_face_at(&self, p: Vec3, tol: f64) -> Option<(String, Plane)> {
|
||||
let tol = tol.min(self.t * 0.25);
|
||||
let flat = if self.flat { self.flat_regions().ok()? } else { Vec::new() };
|
||||
@@ -2727,9 +2728,167 @@ impl SheetBody {
|
||||
return Some((format!("{}:{}", self.panel_name(pi), if top { "top" } else { "bottom" }), self.panel_face_plane(&world, top)?));
|
||||
}
|
||||
}
|
||||
self.rim_face_at(p, tol)
|
||||
}
|
||||
|
||||
/// The straight edges of each panel's outline (folded sheets only) with the name of the rim
|
||||
/// face along each: `<panel>:rim.<edge>`, the edge as the sheet's edge names give it (a base
|
||||
/// sketch line's id, or `far`, `side0`, `side1` of a flange).
|
||||
fn rims(&self) -> Vec<(Option<usize>, Vec2, Vec2, String)> {
|
||||
if self.flat {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for (pi, region) in self.panels() {
|
||||
let pts = dedup(®ion.outer.ccw().polyline(1e-3));
|
||||
let m = pts.len();
|
||||
for i in 0..m {
|
||||
let (Some(&a), Some(&b)) = (pts.get(i), pts.get((i + 1) % m)) else { continue };
|
||||
if a.dist(b) < 1e-9 {
|
||||
continue;
|
||||
}
|
||||
let panel = self.panel_name(pi);
|
||||
let Some(edge) = self.rim_edge(pi, a, b, i) else { continue };
|
||||
if edge.is_empty() || edge.contains(':') || edge == "bend" {
|
||||
continue;
|
||||
}
|
||||
out.push((pi, a, b, format!("{panel}:rim.{edge}")));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What names the rim along edge `a`→`b` (the `k`-th of its panel's outline): the base sketch
|
||||
/// line it lies on (else its place in the base's outline, `edge<k>`, for a base drawn by the
|
||||
/// feature itself), or a flange's `far`, `side0` or `side1` (`bend` where it meets its bend).
|
||||
fn rim_edge(&self, pi: Option<usize>, a: Vec2, b: Vec2, k: usize) -> Option<String> {
|
||||
let d = (b - a).normalized()?;
|
||||
match pi {
|
||||
None => match self.edge_name_of(None, a, b, true) {
|
||||
Some(edge) => edge.strip_prefix("base:")?.strip_suffix(":top").map(str::to_string),
|
||||
None if self.base_curves.is_empty() => Some(format!("edge{k}")),
|
||||
None => None,
|
||||
},
|
||||
Some(i) => {
|
||||
let f = self.flanges.get(i)?;
|
||||
Some(
|
||||
if d.cross(f.d).abs() < 1e-6 {
|
||||
if ((a - f.p).dot(f.n)) > self.allowance(f) + 1e-6 { "far" } else { "bend" }
|
||||
} else if ((a + b) * 0.5 - f.p).dot(f.d) < f.len / 2.0 {
|
||||
"side0"
|
||||
} else {
|
||||
"side1"
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The flat rim face (the sheet's thickness along a straight outline edge of a panel) that a
|
||||
/// point lies on (sheet coordinates, within `tol`): its persistent name and plane (see
|
||||
/// [`panel_face`](Self::panel_face)).
|
||||
fn rim_face_at(&self, p: Vec3, tol: f64) -> Option<(String, Plane)> {
|
||||
let mut best: Option<(f64, String)> = None;
|
||||
for (pi, a, b, name) in self.rims() {
|
||||
let Some(inverse) = mat_inverse(&self.world(pi)) else { continue };
|
||||
let q = apply_point(&inverse, p);
|
||||
if q.z < -tol || q.z > self.t + tol {
|
||||
continue;
|
||||
}
|
||||
let q2 = Vec2::new(q.x, q.y);
|
||||
let d = b - a;
|
||||
let along = (q2 - a).dot(d) / d.len2();
|
||||
if !(-1e-9..=1.0 + 1e-9).contains(&along) {
|
||||
continue;
|
||||
}
|
||||
let off = ((a + d * along) - q2).len();
|
||||
if off <= tol && best.as_ref().is_none_or(|(o, _)| off < *o) {
|
||||
best = Some((off, name));
|
||||
}
|
||||
}
|
||||
let name = match best {
|
||||
Some((_, name)) => name,
|
||||
None => self.bend_side_at(p, tol)?,
|
||||
};
|
||||
let plane = self.panel_face(&name)?;
|
||||
Some((name, plane))
|
||||
}
|
||||
|
||||
/// A flange's bend in its parent's flat coordinates: the parent's placement, the bend axis
|
||||
/// (a point and unit direction along the flange's edge), and the turn's start direction
|
||||
/// (from the axis to the material) and the direction it turns toward.
|
||||
fn bend_frame(&self, f: &SheetFlange) -> Option<(Mat, Vec3, Vec3, Vec3, Vec3)> {
|
||||
let (o, _, _) = self.bend_axis(f);
|
||||
let d = v3(f.d).normalized()?;
|
||||
let start = if f.angle >= 0.0 { Vec3::Z * -1.0 } else { Vec3::Z };
|
||||
let toward = v3(f.n).normalized()?;
|
||||
Some((self.world(f.parent), o, d, start, toward))
|
||||
}
|
||||
|
||||
/// The flat end of a bend (where the bend stops along its flange's edge: an annular sector
|
||||
/// square to the bend axis) that a point lies on: `<flange>:rim.bend0` at its start,
|
||||
/// `rim.bend1` at its end.
|
||||
fn bend_side_at(&self, p: Vec3, tol: f64) -> Option<String> {
|
||||
if self.flat {
|
||||
return None;
|
||||
}
|
||||
for (i, f) in self.flanges.iter().enumerate() {
|
||||
let Some((world, o, d, start, toward)) = self.bend_frame(f) else { continue };
|
||||
let Some(inverse) = mat_inverse(&world) else { continue };
|
||||
let q = apply_point(&inverse, p) - o;
|
||||
let u = q.dot(d);
|
||||
let radial = q - d * u;
|
||||
let rho = radial.len();
|
||||
if rho < f.radius - tol || rho > f.radius + self.t + tol {
|
||||
continue;
|
||||
}
|
||||
let phi = radial.dot(toward).atan2(radial.dot(start));
|
||||
if phi < -1e-6 || phi > f.angle.abs() + 1e-6 {
|
||||
continue;
|
||||
}
|
||||
let (u0, u1) = self.zone_span(f);
|
||||
let side = if (u - u0).abs() <= tol {
|
||||
"bend0"
|
||||
} else if (u - u1).abs() <= tol {
|
||||
"bend1"
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
return Some(format!("{}:rim.{side}", self.panel_name(Some(i))));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The plane of a bend's flat end (see [`bend_side_at`](Self::bend_side_at)): facing out
|
||||
/// along the bend axis, its origin on the axis, its x across the sheet.
|
||||
fn bend_side(&self, i: usize, end: bool) -> Option<Plane> {
|
||||
let f = self.flanges.get(i)?;
|
||||
let (world, o, d, _, _) = self.bend_frame(f)?;
|
||||
let (u0, u1) = self.zone_span(f);
|
||||
let (u, out) = if end { (u1, d) } else { (u0, d * -1.0) };
|
||||
let origin = o + d * u;
|
||||
Plane::new(apply_point(&world, origin), apply_vector(&world, Vec3::Z), apply_vector(&world, out.cross(Vec3::Z)))
|
||||
}
|
||||
|
||||
/// The plane of the rim face named `rim` (`<edge>` of `rim.<edge>`) of panel `pi`: on the
|
||||
/// rim, facing out of the panel, its x along the outline (counterclockwise about the panel's
|
||||
/// top) and its y across the thickness, its origin where the panel's origin projects onto it.
|
||||
fn rim_face(&self, pi: Option<usize>, rim: &str) -> Option<Plane> {
|
||||
match (pi, rim) {
|
||||
(Some(i), "bend0") => return self.bend_side(i, false),
|
||||
(Some(i), "bend1") => return self.bend_side(i, true),
|
||||
_ => {}
|
||||
}
|
||||
let panel = self.panel_name(pi);
|
||||
let name = format!("{panel}:rim.{rim}");
|
||||
let (_, a, b, _) = self.rims().into_iter().find(|(p, _, _, n)| *p == pi && *n == name)?;
|
||||
let world = self.world(pi);
|
||||
let d = (b - a).normalized()?;
|
||||
let foot = a + d * (Vec2::default() - a).dot(d);
|
||||
Plane::new(apply_point(&world, Vec3::new(foot.x, foot.y, 0.0)), apply_vector(&world, Vec3::new(d.x, d.y, 0.0)), apply_vector(&world, Vec3::Z))
|
||||
}
|
||||
|
||||
/// The plane of the flat face with this persistent name (see [`panel_face_at`](Self::panel_face_at)).
|
||||
/// A face named while the sheet was folded follows its panel; one named unfolded (`flat`)
|
||||
/// is the flat sheet's own plane.
|
||||
@@ -2738,6 +2897,7 @@ impl SheetBody {
|
||||
let top = match side {
|
||||
"top" => true,
|
||||
"bottom" => false,
|
||||
_ if side.starts_with("rim.") => false,
|
||||
_ => return None,
|
||||
};
|
||||
let pi = match panel {
|
||||
@@ -2749,6 +2909,9 @@ impl SheetBody {
|
||||
.or_else(|| tag.strip_prefix('#')?.parse::<usize>().ok().filter(|i| *i < self.flanges.len()))?,
|
||||
),
|
||||
};
|
||||
if let Some(rim) = side.strip_prefix("rim.") {
|
||||
return self.rim_face(pi, rim);
|
||||
}
|
||||
self.panel_face_plane(&self.world(pi), top)
|
||||
}
|
||||
|
||||
|
||||
@@ -164,3 +164,166 @@ fn a_piece_of_a_face_split_on_purpose_still_needs_a_fixed_plane() {
|
||||
let error = s.execute("sketch.create", &json!({"plane": {"face": [2.5, 5, 10]}})).unwrap_err().to_string();
|
||||
assert!(error.contains("fixed:true"), "{error}");
|
||||
}
|
||||
|
||||
/// Offset Face raises one piece of a split top: the pieces no longer share a plane, so each
|
||||
/// takes a sketch by its own name (the face's, qualified by the faces around it) and follows it.
|
||||
#[test]
|
||||
fn a_raised_piece_of_a_split_top_takes_a_sketch_by_its_own_name() {
|
||||
let mut s = slotted_block();
|
||||
run(&mut s, "parameters.add", json!({"name": "raise", "expression": "2 mm"}));
|
||||
run(&mut s, "solid.offset_face", json!({"faces": [[5, 15, 10]], "distance": "raise", "name": "Raise"}));
|
||||
no_errors(&s);
|
||||
sketch_on(&mut s, [5., 15., 12.], "Raised");
|
||||
sketch_on(&mut s, [35., 15., 10.], "Kept");
|
||||
let (raised, certificate) = healthy(&s, "Raised");
|
||||
assert_eq!(certificate, Some(FaceCertificate::Lineage));
|
||||
assert!((raised.origin.z - 12.).abs() < 1e-9 && (raised.normal().z - 1.).abs() < 1e-9, "{raised:?}");
|
||||
assert!((healthy(&s, "Kept").0.origin.z - 10.).abs() < 1e-9);
|
||||
run(&mut s, "parameters.change", json!({"name": "raise", "expression": "3 mm"}));
|
||||
no_errors(&s);
|
||||
assert!((healthy(&s, "Raised").0.origin.z - 13.).abs() < 1e-9);
|
||||
assert!((healthy(&s, "Kept").0.origin.z - 10.).abs() < 1e-9);
|
||||
let t = reopened(&s);
|
||||
assert!((healthy(&t, "Raised").0.origin.z - 13.).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// A plastic Boss keeps the history of the body it stands on (#494): the plate's faces still
|
||||
/// take attached sketches, and so does the boss's own top, which follows its height.
|
||||
#[test]
|
||||
fn faces_after_a_boss_take_a_sketch_that_follows_it() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "boss_h", "expression": "12 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length": 80, "width": 50, "height": 2, "name": "Plate"}));
|
||||
run(
|
||||
&mut s,
|
||||
"plastic.boss",
|
||||
json!({"position": [20, 20, 2], "diameter": 7, "height": "boss_h", "hole_diameter": 2.5, "fillet": 0.5, "name": "Boss"}),
|
||||
);
|
||||
no_errors(&s);
|
||||
sketch_on(&mut s, [5., 25., 2.], "OnPlate");
|
||||
sketch_on(&mut s, [80., 25., 1.], "OnSide");
|
||||
sketch_on(&mut s, [22.5, 20., 14.], "OnBoss");
|
||||
for name in ["OnPlate", "OnSide"] {
|
||||
healthy(&s, name);
|
||||
}
|
||||
let (top, certificate) = healthy(&s, "OnBoss");
|
||||
assert_eq!(certificate, Some(FaceCertificate::Lineage));
|
||||
assert!((top.origin.z - 14.).abs() < 1e-9, "{top:?}");
|
||||
run(&mut s, "parameters.change", json!({"name": "boss_h", "expression": "15 mm"}));
|
||||
no_errors(&s);
|
||||
assert!((healthy(&s, "OnBoss").0.origin.z - 17.).abs() < 1e-9);
|
||||
healthy(&reopened(&s), "OnBoss");
|
||||
}
|
||||
|
||||
/// A Hole names its faces by the part they cut (a counterbore's floor is `F<hole>:cbore:base`),
|
||||
/// and a pattern's copies of it as copies: each floor takes its own sketch, which follows the
|
||||
/// counterbore's depth. The plate's faces stay attachable too.
|
||||
#[test]
|
||||
fn counterbore_floors_and_their_pattern_copies_take_a_sketch() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "cb", "expression": "3 mm"}));
|
||||
run(&mut s, "solid.box", json!({"length": 80, "width": 30, "height": 10, "name": "Plate"}));
|
||||
run(
|
||||
&mut s,
|
||||
"solid.hole",
|
||||
json!({"position": [15, 15, 10], "diameter": 5, "type": "counterbore", "cb_diameter": 10, "cb_depth": "cb", "name": "Hole"}),
|
||||
);
|
||||
run(&mut s, "solid.pattern.rectangular", json!({"features": ["Hole"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 25, "name": "Row"}));
|
||||
no_errors(&s);
|
||||
for (name, x) in [("Seed", 18.5), ("Copy", 43.5), ("Top", 30.)] {
|
||||
sketch_on(&mut s, [x, 15., if name == "Top" { 10. } else { 7. }], name);
|
||||
}
|
||||
for name in ["Seed", "Copy"] {
|
||||
let (floor, certificate) = healthy(&s, name);
|
||||
assert_eq!(certificate, Some(FaceCertificate::Lineage), "{name}");
|
||||
assert!((floor.origin.z - 7.).abs() < 1e-9, "{name}: {floor:?}");
|
||||
}
|
||||
healthy(&s, "Top");
|
||||
run(&mut s, "parameters.change", json!({"name": "cb", "expression": "4 mm"}));
|
||||
no_errors(&s);
|
||||
for name in ["Seed", "Copy"] {
|
||||
assert!((healthy(&s, name).0.origin.z - 6.).abs() < 1e-9, "{name}");
|
||||
}
|
||||
healthy(&reopened(&s), "Copy");
|
||||
}
|
||||
|
||||
/// The middle of the largest triangle of each flat face of the first body.
|
||||
fn flat_faces(s: &Session) -> Vec<(usize, solidcraft_geom::Vec3)> {
|
||||
let state = s.model.state();
|
||||
let body = &state.bodies[0].body;
|
||||
let mesh = body.tessellate(0.01).unwrap();
|
||||
(0..body.face_count())
|
||||
.filter(|index| body.planar_face(*index).is_some())
|
||||
.filter_map(|index| {
|
||||
let point = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.filter(|(_, f)| **f as usize == index)
|
||||
.filter_map(|(t, _)| mesh.tri(t))
|
||||
.max_by(|[a, b, c], [d, e, f]| (*b - *a).cross(*c - *a).len().total_cmp(&(*e - *d).cross(*f - *d).len()))
|
||||
.map(|[a, b, c]| (a + b + c) * (1.0 / 3.0))?;
|
||||
Some((index, point))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every flat face of the first body takes an attached sketch (#681: a flat face is a flat face,
|
||||
/// whichever features it was merged from).
|
||||
fn every_flat_face_takes_a_sketch(s: &mut Session) {
|
||||
no_errors(s);
|
||||
let faces = flat_faces(s);
|
||||
assert!(faces.len() >= 8, "{}", faces.len());
|
||||
let mut failed = Vec::new();
|
||||
for (index, point) in faces {
|
||||
match s.execute("sketch.create", &json!({"plane": {"face": point, "body": "Body1", "index": index}})) {
|
||||
Ok(made) => {
|
||||
run(s, "sketch.finish", json!({}));
|
||||
let id = made["sketch"].as_u64().unwrap();
|
||||
if !matches!(s.placed_document().sketch_face_attachment(id, &s.model), FaceAttachmentStatus::Healthy { .. }) {
|
||||
failed.push(format!("face {index} at {point:?}: not healthy"));
|
||||
}
|
||||
}
|
||||
Err(e) => failed.push(format!("face {index} at {point:?}: {e}")),
|
||||
}
|
||||
}
|
||||
assert!(failed.is_empty(), "{}", failed.join("\n"));
|
||||
}
|
||||
|
||||
/// #681: a block with a lower step joined flush beside it. The step's side walls continue the
|
||||
/// block's (a Join merged them), and they take a sketch like every other flat face.
|
||||
#[test]
|
||||
fn every_flat_face_of_a_block_and_a_flush_step_takes_a_sketch() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "A"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "A", "distance": 10, "name": "Block"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "B"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 10], "p1": [20, 20]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "B", "distance": 5, "operation": "join", "name": "Step"}));
|
||||
every_flat_face_takes_a_sketch(&mut s);
|
||||
}
|
||||
|
||||
/// #681 as reported: one sketch of a 2 x 2 grid, three cells extruded up (an elbow) and three
|
||||
/// joined down (the inverse elbow).
|
||||
#[test]
|
||||
#[ignore = "the join of the elbow down fails until #679 is fixed"]
|
||||
fn every_flat_face_of_an_elbow_up_and_an_elbow_down_takes_a_sketch() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Grid"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [20, 0], [20, 20], [0, 20]], "closed": true}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[10, 0], [10, 20]]}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 10], [20, 10]]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let cells = |pts: [[f64; 2]; 3]| pts.iter().map(|p| json!({"point": p})).collect::<Vec<_>>();
|
||||
run(&mut s, "solid.extrude", json!({"sketch": "Grid", "distance": 10, "profiles": cells([[5., 5.], [15., 5.], [5., 15.]]), "name": "Up"}));
|
||||
let down = cells([[15., 5.], [5., 15.], [15., 15.]]);
|
||||
run(
|
||||
&mut s,
|
||||
"solid.extrude",
|
||||
json!({"sketch": "Grid", "distance": 10, "direction": "negative", "operation": "join", "profiles": down, "name": "Down"}),
|
||||
);
|
||||
every_flat_face_takes_a_sketch(&mut s);
|
||||
}
|
||||
@@ -3023,3 +3023,79 @@ fn fold_fixed_point_must_touch_the_sheet_but_any_face_will_do() {
|
||||
assert!(fold["warning"].as_str().is_some_and(|w| w.contains("fixed point is not on the sheet")), "{fold}");
|
||||
assert!((bbox(&mut f)["max"][0].as_f64().unwrap_or(f64::NAN) - x).abs() < 1e-6, "the right side stays");
|
||||
}
|
||||
|
||||
/// The middle of the largest triangle of each flat face of the first body: well inside it.
|
||||
fn flat_face_points(s: &Session) -> Vec<(usize, solidcraft_geom::Vec3, solidcraft_geom::Vec3)> {
|
||||
let state = s.model.state();
|
||||
let body = &state.bodies[0].body;
|
||||
let mesh = body.tessellate(0.01).unwrap();
|
||||
(0..body.face_count())
|
||||
.filter_map(|index| {
|
||||
let normal = body.planar_face(index)?.normal();
|
||||
let point = mesh
|
||||
.triangles
|
||||
.iter()
|
||||
.zip(&mesh.tri_face)
|
||||
.filter(|(_, f)| **f as usize == index)
|
||||
.filter_map(|(t, _)| mesh.tri(t))
|
||||
.max_by(|[a, b, c], [d, e, f]| (*b - *a).cross(*c - *a).len().total_cmp(&(*e - *d).cross(*f - *d).len()))
|
||||
.map(|[a, b, c]| (a + b + c) * (1.0 / 3.0))?;
|
||||
Some((index, point, normal))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every flat face of a sheet takes an attached sketch (#495): its panels, the rims along the
|
||||
/// panels' straight edges (named by the base sketch line or the flange's far edge and sides) and
|
||||
/// the flat ends of a bend. Each lies on its face, facing out of the sheet, and a rim's sketch
|
||||
/// follows the rim when the base grows. A press-pull on a rim pushes it out, and a plain hole into
|
||||
/// a panel is kept as a cut-out of the flat pattern (#560).
|
||||
#[test]
|
||||
fn every_flat_face_of_a_sheet_takes_an_attached_sketch() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "parameters.add", json!({"name": "W", "expression": "100 mm"}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [100, 60]}));
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": ["p1"]}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": "W"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "sheet.flange", json!({"sketch": "Base"}));
|
||||
run(&mut s, "sheet.flange", json!({"edges": [[50, 0, 2.5]], "height": 20}));
|
||||
let faces = flat_face_points(&s);
|
||||
// The base: its two faces and three rims (the fourth edge bends); the flange: its two faces,
|
||||
// its far rim and two sides; the two ends of the bend.
|
||||
assert_eq!(faces.len(), 12);
|
||||
let mut on_side = None;
|
||||
for (index, point, normal) in faces {
|
||||
let made = run(&mut s, "sketch.create", json!({"plane": {"face": point, "body": "Body1", "index": index}}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
assert_eq!(made["attachment"], "sheet_face", "face {index} at {point:?}");
|
||||
let id = made["sketch"].as_u64().unwrap();
|
||||
let solidcraft_doc::FaceAttachmentStatus::Sheet { plane: Some(plane) } = s.placed_document().sketch_face_attachment(id, &s.model) else {
|
||||
panic!("face {index} at {point:?}")
|
||||
};
|
||||
assert!(plane.normal().dot(normal) > 1.0 - 1e-9 && (point - plane.origin).dot(normal).abs() < 1e-6, "face {index}: {plane:?}");
|
||||
if (point.x - 100.0).abs() < 1e-6 && point.y > 10.0 {
|
||||
on_side = Some(id);
|
||||
}
|
||||
}
|
||||
// The base's rim along x = W (the sketch's l2) moves with W.
|
||||
let side = on_side.expect("the base rim on x = 100");
|
||||
run(&mut s, "parameters.change", json!({"name": "W", "expression": "120 mm"}));
|
||||
let solidcraft_doc::FaceAttachmentStatus::Sheet { plane: Some(plane) } = s.placed_document().sketch_face_attachment(side, &s.model) else {
|
||||
panic!("the rim sketch after the edit")
|
||||
};
|
||||
assert!((plane.origin.x - 120.0).abs() < 1e-9 && (plane.normal().x - 1.0).abs() < 1e-9, "{plane:?}");
|
||||
// Press-pull the rim 5 mm out: the base grows by 5 x 60 x 2.5 less what the flange covers.
|
||||
let v0 = volume(&mut s);
|
||||
run(&mut s, "solid.press_pull", json!({"face": [120, 30, 1.25], "distance": 5}));
|
||||
assert!(volume(&mut s) > v0 + 5.0 * 50.0 * 2.5, "{} {v0}", volume(&mut s));
|
||||
// A plain hole into the base is a cut-out of the flat pattern.
|
||||
let mut t = Session::default();
|
||||
plate_with_flange(&mut t);
|
||||
let v0 = volume(&mut t);
|
||||
run(&mut t, "solid.hole", json!({"position": [50, 30, 2.5], "direction": [0, 0, -1], "diameter": 6}));
|
||||
assert!((v0 - volume(&mut t) - PI * 9.0 * 2.5).abs() < 0.1, "{}", v0 - volume(&mut t));
|
||||
let f = flat(&mut t);
|
||||
assert_eq!(f["cutouts"].as_array().map(Vec::len), Some(1), "{f}");
|
||||
}
|
||||
@@ -105,10 +105,10 @@ fn capture_impl(s: &Session, value: &Value, cmd: &str, internal: bool) -> Result
|
||||
let inverse = mat_inverse(&frame).ok_or_else(|| bad(cmd, "invalid selected face frame"))?;
|
||||
let local_point = apply_point(&inverse, point);
|
||||
let tolerance = (body.body.size() * 1e-4).max(1e-5);
|
||||
// A flat face of a sheet metal body attaches to its panel: the exact plane comes from
|
||||
// the panel's flat region and fold, and the sketch keeps the face's name to follow it.
|
||||
// A flat face of a sheet metal body attaches to its panel (or a panel's rim): the
|
||||
// exact plane comes from the panel's flat region and fold, and the sketch keeps the
|
||||
// face's name to follow it.
|
||||
if !fixed
|
||||
&& !internal
|
||||
&& let Some(sheet) = state.sheets.iter().find(|sheet| sheet.body == body.name)
|
||||
&& let Some((face, plane)) = sheet.panel_face_at(local_point, tolerance * 10.0)
|
||||
{
|
||||
|
||||
@@ -268,7 +268,9 @@ Where the face history stands (`crates/kernel/src/history.rs`, [face attachments
|
||||
a flat face takes an attached sketch when the body carries an exact source certificate for it
|
||||
(extrude caps and sides, boxes, shells' inner walls, STEP faces, sheet metal panels), or else
|
||||
when its persistent name follows exact history (a `lineage` certificate): cut walls and floors,
|
||||
chamfers, pattern and mirror copies, sides a Join merged, and pieces of a split face. What still
|
||||
refuses a sketch is what makes faces without recording their history (revolves in some cases,
|
||||
Offset Face, the plastic features, lofts, sweeps, drafts, tapers, shell rims, sheet metal rims):
|
||||
press-pull and extrude-from-face then fall back to a legacy face sketch found again by a point.
|
||||
chamfers, pattern and mirror copies, sides a Join merged, revolves, lofts, sweeps, drafts, tapers,
|
||||
shell rims, the plastic features' faces and the faces around them, and pieces of a split face (by
|
||||
the piece's own name where Offset Face moved it off the others' plane). Sheet metal panels, their
|
||||
rims and the flat ends of bends attach by name. What still refuses a sketch is what makes faces
|
||||
without recording their history (some cuts, Split Body, Intersect): press-pull and
|
||||
extrude-from-face then fall back to a legacy face sketch found again by a point.
|
||||
@@ -29,16 +29,29 @@ carry their seed's construction certificate, so a sketch attached to the seed be
|
||||
patterned keeps resolving to the seed: of the faces carrying one certificate, the producing
|
||||
feature's own face is the one whose name is not a copy's.
|
||||
|
||||
Faces whose history is not exact (Offset Face, the plastic features, sheet metal rims, faces a
|
||||
feature rebuilt without reporting its history), ambiguous sources, and the pieces of a face
|
||||
split on purpose with Split Face (each piece is its own face) require an explicit fixed plane
|
||||
instead. The error says why, and the Create Sketch dialog then offers **Use fixed plane**,
|
||||
Where the pieces of one name no longer share a plane (Offset Face raised one piece of a top a
|
||||
cut had split), each piece is attached by its own name: the face's, qualified by the faces
|
||||
around it (`F2:top#[F2:side:l4,F4:side:l4]`). The plastic features (Boss, Lip, Rest, Snap Fit)
|
||||
keep the history of the body they go on and name their own faces by the part they make
|
||||
(`F4:post:top`, `F5:rest:side:0`, `F6:arm:top`), so those take attachments too. A side an extrude grew to
|
||||
overlap a flush body before joining it (its neighbours lengthened past the sketch's corners) is
|
||||
still named by the sketch line it runs along, so a wall a Join merged from two extrudes' walls
|
||||
attaches by the first one's name and answers to the other's.
|
||||
|
||||
Faces whose history is not exact (faces a feature rebuilt without reporting its history),
|
||||
ambiguous sources, and the pieces of a face split on purpose with Split Face (each piece is its
|
||||
own face) require an explicit fixed plane instead. The error says why, and the Create Sketch dialog then offers **Use fixed plane**,
|
||||
which places the sketch on that face's current plane without following later changes. A
|
||||
nearby or parallel face is never used to repair a new attachment automatically.
|
||||
|
||||
The flat faces of a sheet metal body are the exception: they have no construction
|
||||
certificate, so a sketch on one attaches to its panel by name (`sheet:<body>:<panel>:<side>`)
|
||||
and finds it again after every rebuild, unfold and refold. If that panel no longer exists the
|
||||
and finds it again after every rebuild, unfold and refold. The rims (the sheet's thickness
|
||||
along a panel's straight edge) attach the same way, by the edge they run along
|
||||
(`<panel>:rim.l3` for a base sketch line, `<flange>:rim.far`, `rim.side0`, `rim.side1`), and so
|
||||
do the flat ends of a bend (`<flange>:rim.bend0`, `rim.bend1`); a rim's plane faces out of the
|
||||
panel, its x along the edge and its y across the thickness. Extrude and Press Pull from a sheet
|
||||
face use the same attachment. If that panel no longer exists the
|
||||
sketch reports a lost attachment, as above. See the sheet metal guide.
|
||||
|
||||
## Create and repair
|
||||
|
||||
+2114
-1392
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user