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 <noreply@anthropic.com>
This commit is contained in:
1 parent
e30f3c9ebe
commit
5ef1dcba8e
15 files changed
+2882
-1503
No files matched your search
@@ -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:top@F6.2`), 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)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user