feat: stabilize face attachments and closed spline workflows

This commit is contained in:
bherbruck committed 2026-10-11 05:32:46 -04:00
1 parent c1d819f235
commit a6bb8fc924
83 files changed
+6271 -385

No files matched your search

+112 -10
View File
@@ -90,6 +90,15 @@ fn region_ok(r: &Region2) -> Result<()> {
/// Extrude each region from `start` to `end` along the plane normal (offsets in mm, `end > start`).
pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Vec<Body>> {
if !plane.origin.is_finite()
|| !plane.x.is_finite()
|| !plane.y.is_finite()
|| (plane.x.len2() - 1.).abs() > 1e-8
|| (plane.y.len2() - 1.).abs() > 1e-8
|| plane.x.dot(plane.y).abs() > 1e-8
{
return Err(KernelError::Invalid("extrude frame is not finite and orthonormal".into()));
}
let (start, end) = (finite(start, "start")?, finite(end, "end")?);
let (lo, hi) = if end >= start { (start, end) } else { (end, start) };
if hi - lo < 1e-6 {
@@ -112,6 +121,31 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
}
let f = guard("extrude profile", || builder::try_attach_plane(&wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))?;
let solid: Solid = guard("extrude", || Ok(builder::tsweep(&f, v3(n * (hi - lo)))))?;
let upper = guard("extrude cap identities", || {
let inputs: std::collections::HashSet<_> = sources.iter().map(|(edge, _)| edge.id()).collect();
let mut visits = 0usize;
let mut candidate = None;
for (index, generated) in solid.face_iter().enumerate() {
if index >= 50_000 {
return Ok(None);
}
let mut side = false;
for edge in generated.absolute_boundaries().iter().flatten() {
visits += 1;
if visits > 200_000 {
return Ok(None);
}
side |= inputs.contains(&edge.id());
}
if generated.id() != f.id() && !side {
if candidate.is_some() {
return Ok(None);
}
candidate = Some(generated.id());
}
}
Ok(candidate)
})?;
let body = Body::new(solid)?;
// tsweep retains each actual input boundary edge on its generated side.
// Match topology identity, never face order, surface samples or centroids.
@@ -120,13 +154,35 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
let mut result = Vec::new();
for generated in body.solid.face_iter() {
let matching: Vec<_> = generated.absolute_boundaries().iter().flatten().filter_map(|edge| source_edges.get(&edge.id())).collect();
let source = if generated.id() == f.id() {
None
} else if let [segment] = matching.as_slice() {
Some(crate::FaceSource::ExtrudeSide { segment: **segment })
} else {
None
};
let source =
if generated.id() == f.id() {
upper.and_then(|_| {
(r.outer.segs.len() + r.holes.iter().map(|hole| hole.segs.len()).sum::<usize>() <= 4096).then(|| {
crate::FaceSource::ExtrudeCap {
high: false,
region: r.clone(),
plane: Plane { origin: base.origin, x: plane.x, y: -plane.y },
}
})
})
} else if Some(generated.id()) == upper {
(r.outer.segs.len() + r.holes.iter().map(|hole| hole.segs.len()).sum::<usize>() <= 4096)
.then(|| crate::FaceSource::ExtrudeCap { high: true, region: r.clone(), plane: plane.offset(hi) })
} else if let [segment] = matching.as_slice() {
let basis = match **segment {
Seg2::Line { a, b } => {
let d = b - a;
d.normalized().and_then(|direction| {
let foot = a - direction * a.dot(direction);
Plane::new(plane.to_world(foot), plane.dir_to_world(direction), n)
})
}
_ => None,
};
Some(crate::FaceSource::ExtrudeSide { segment: **segment, plane: basis })
} else {
None
};
result.push(source);
}
Ok(result)
@@ -136,8 +192,25 @@ pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Resu
Ok(out)
}
/// Revolve each region about the axis through sketch point `axis_origin` with sketch direction
/// `axis_dir` by `angle` radians (|angle| ≥ 2π = full revolution).
/// Choose the per-profile axis sign whose positive rotation follows the sketch normal.
pub fn canonical_revolve_axis(plane: &Plane, region: &Region2, axis_origin: Vec2, axis_dir: Vec2) -> Result<Vec3> {
if !axis_origin.is_finite() || !axis_dir.is_finite() || !plane.origin.is_finite() || !plane.x.is_finite() || !plane.y.is_finite() {
return Err(KernelError::Invalid("non-finite revolve frame".into()));
}
if (plane.x.len2() - 1.).abs() > 1e-8 || (plane.y.len2() - 1.).abs() > 1e-8 || plane.x.dot(plane.y).abs() > 1e-8 {
return Err(KernelError::Invalid("revolve frame is not orthonormal".into()));
}
region_ok(region)?;
let direction = axis_dir.normalized().ok_or_else(|| KernelError::Invalid("revolve axis has no direction".into()))?;
let axis = plane.dir_to_world(direction).normalized().ok_or_else(|| KernelError::Invalid("revolve frame has no axis".into()))?;
let origin = plane.to_world(axis_origin);
let center = plane.to_world(region.centroid());
let radial = center - origin - axis * (center - origin).dot(axis);
Ok(if axis.cross(radial).dot(plane.normal()) < 0. { -axis } else { axis })
}
/// Revolve each region about the sketch axis by `angle` radians.
/// This legacy API preserves its historical sweep sign behavior.
pub fn revolve(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: Vec2, angle: f64) -> Result<Vec<Body>> {
let angle = finite(angle, "angle")?;
if angle.abs() < 1e-6 {
@@ -264,7 +337,36 @@ pub fn box_solid(a: Vec3, b: Vec3) -> Result<Body> {
holes: vec![],
};
let mut v = extrude(&Plane::XY, &[r], lo.z, hi.z)?;
v.pop().ok_or_else(|| KernelError::Failed("box".into()))
let body = v.pop().ok_or_else(|| KernelError::Failed("box".into()))?;
let sources = body.face_sources().ok_or_else(|| KernelError::Failed("box construction source".into()))?;
let mut roles = std::collections::HashSet::new();
let mut certified = Vec::new();
for source in sources {
let role = match source {
Some(crate::FaceSource::ExtrudeCap { high, .. }) => Some((2u8, *high)),
Some(crate::FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.x == b.x && a.x == lo.x => Some((0, false)),
Some(crate::FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.x == b.x && a.x == hi.x => Some((0, true)),
Some(crate::FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.y == b.y && a.y == lo.y => Some((1, false)),
Some(crate::FaceSource::ExtrudeSide { segment: Seg2::Line { a, b }, .. }) if a.y == b.y && a.y == hi.y => Some((1, true)),
_ => None,
}
.ok_or_else(|| KernelError::Failed("box role correspondence".into()))?;
if !roles.insert(role) {
return Err(KernelError::Failed("duplicate box role".into()));
}
let (axis, high) = role;
let sign = if high { 1. } else { -1. };
let plane = match axis {
0 => Plane { origin: Vec3::X * if high { hi.x } else { lo.x }, x: Vec3::Y, y: Vec3::Z * sign },
1 => Plane { origin: Vec3::Y * if high { hi.y } else { lo.y }, x: Vec3::X, y: -Vec3::Z * sign },
_ => Plane { origin: Vec3::Z * if high { hi.z } else { lo.z }, x: Vec3::X, y: Vec3::Y * sign },
};
certified.push(Some(crate::FaceSource::Box { producer: None, axis, high, plane }));
}
if roles.len() != 6 {
return Err(KernelError::Failed("box role count".into()));
}
Ok(body.with_face_sources(Some(std::sync::Arc::new(certified))))
}
/// Where primitives start their circles (radians from the frame's x axis).