* WIP deep rename (not for push) * WIP2 * WIP3 * WIP4 wxs fix * rustfmt * Rename the remaining identifiers in merged crash-report code Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT --------- Co-authored-by: Claude Opus 5.5 <[email protected]>
1305 lines
65 KiB
Rust
1305 lines
65 KiB
Rust
//! Building bodies: extrude and revolve planar regions, primitives.
|
||
|
||
use mt::builder;
|
||
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
|
||
use truck_modeling as mt;
|
||
|
||
use crate::body::{Body, Solid, from_p3, p3, v3};
|
||
use crate::history::FaceProv;
|
||
use crate::numbered::{self, NumberedLoop, NumberedRegion};
|
||
use crate::record::{self, Side};
|
||
use crate::{KernelError, Result, guard};
|
||
|
||
const MAX_SEGS: usize = 20_000;
|
||
|
||
fn finite(x: f64, what: &str) -> Result<f64> {
|
||
if x.is_finite() && x.abs() < 1e7 { Ok(x) } else { Err(KernelError::Invalid(format!("{what} = {x}"))) }
|
||
}
|
||
|
||
/// A closed wire in 3D from a sketch loop.
|
||
pub(crate) fn wire(plane: &Plane, lp: &Loop2) -> Result<mt::Wire> {
|
||
wire_sources(plane, lp).map(|(wire, _)| wire)
|
||
}
|
||
|
||
/// A closed wire in 3D from a sketch loop, with each edge's segment and that segment's
|
||
/// position in the loop (a big arc makes two edges).
|
||
fn wire_sources(plane: &Plane, lp: &Loop2) -> Result<(mt::Wire, Vec<(mt::Edge, Seg2, usize)>)> {
|
||
let n = lp.segs.len();
|
||
if n == 0 || n > MAX_SEGS {
|
||
return Err(KernelError::Invalid(format!("loop with {n} segments")));
|
||
}
|
||
if !lp.is_closed(1e-6) {
|
||
return Err(KernelError::Invalid("profile loop is not closed".into()));
|
||
}
|
||
let verts: Vec<mt::Vertex> = lp.segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect();
|
||
let mut edges = Vec::with_capacity(n);
|
||
let mut sources = Vec::with_capacity(n);
|
||
for (i, s) in lp.segs.iter().enumerate() {
|
||
let (Some(a), Some(b)) = (verts.get(i), verts.get((i + 1) % n)) else { continue };
|
||
let e: mt::Edge = match *s {
|
||
Seg2::Line { .. } => builder::line(a, b),
|
||
Seg2::Arc { sweep, .. } => {
|
||
if sweep.abs() < 1e-9 {
|
||
return Err(KernelError::Invalid("zero-length arc".into()));
|
||
}
|
||
// Split big arcs so every piece is well conditioned.
|
||
if sweep.abs() > std::f64::consts::PI * 1.5 {
|
||
let vm = builder::vertex(p3(plane.to_world(s.mid())));
|
||
let first = builder::circle_arc(a, &vm, p3(plane.to_world(s.point_at(0.25))));
|
||
sources.push((first.clone(), *s, i));
|
||
edges.push(first);
|
||
builder::circle_arc(&vm, b, p3(plane.to_world(s.point_at(0.75))))
|
||
} else {
|
||
builder::circle_arc(a, b, p3(plane.to_world(s.mid())))
|
||
}
|
||
}
|
||
Seg2::Cubic { .. } | Seg2::Conic { .. } | Seg2::Bezier { .. } => {
|
||
crate::freeform::edge(plane, s, a, b).ok_or_else(|| KernelError::Invalid("profile curve".into()))?
|
||
}
|
||
};
|
||
sources.push((e.clone(), *s, i));
|
||
edges.push(e);
|
||
}
|
||
Ok((edges.into(), sources))
|
||
}
|
||
|
||
/// An edge of a profile wire: the segment it was built from and the profile segments under it.
|
||
pub(crate) struct ProfileEdge {
|
||
pub edge: mt::Edge,
|
||
pub segment: Seg2,
|
||
pub numbers: Vec<usize>,
|
||
}
|
||
|
||
/// A wire built from a profile loop, with its edges in wire order.
|
||
pub(crate) struct ProfileWire {
|
||
pub wire: mt::Wire,
|
||
pub edges: Vec<ProfileEdge>,
|
||
}
|
||
|
||
impl ProfileWire {
|
||
/// The profile segments under each edge, in wire order.
|
||
fn numbers(&self) -> Vec<Vec<usize>> {
|
||
self.edges.iter().map(|e| e.numbers.clone()).collect()
|
||
}
|
||
}
|
||
|
||
/// The wires of a region oriented as the builders build it ([`NumberedRegion::oriented`]), each
|
||
/// with what its edges were built from.
|
||
fn region_wires(plane: &Plane, r: &NumberedRegion) -> Result<Vec<ProfileWire>> {
|
||
r.loops()
|
||
.map(|lp| {
|
||
let (wire, s) = wire_sources(plane, &lp.lp)?;
|
||
let edges = s.into_iter().map(|(edge, segment, i)| ProfileEdge { edge, segment, numbers: lp.numbers(i).to_vec() }).collect();
|
||
Ok(ProfileWire { wire, edges })
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// The wires alone.
|
||
fn wires_of(profile: &[ProfileWire]) -> Vec<mt::Wire> {
|
||
profile.iter().map(|p| p.wire.clone()).collect()
|
||
}
|
||
|
||
/// The profile edges as sweep seeds: each edge with the profile segments under it.
|
||
fn seeds(profile: &[ProfileWire]) -> Vec<(mt::EdgeID, Vec<usize>)> {
|
||
profile.iter().flat_map(|p| &p.edges).map(|e| (e.edge.id(), e.numbers.clone())).collect()
|
||
}
|
||
|
||
/// A region numbered on its own, as given.
|
||
fn numbered_region(r: &Region2) -> Result<NumberedRegion> {
|
||
numbered::number_regions(std::slice::from_ref(r)).pop().ok_or_else(|| KernelError::Invalid("profile".into()))
|
||
}
|
||
|
||
/// A region numbered on its own and oriented as the builders build it.
|
||
fn oriented(r: &Region2) -> Result<NumberedRegion> {
|
||
numbered_region(r).map(|n| n.oriented())
|
||
}
|
||
|
||
/// A planar face from a region (outer loop with holes).
|
||
pub(crate) fn face(plane: &Plane, r: &Region2) -> Result<mt::Face> {
|
||
attach_profile(&wires_of(®ion_wires(plane, &oriented(r)?)?))
|
||
}
|
||
|
||
fn attach_profile(wires: &[mt::Wire]) -> Result<mt::Face> {
|
||
guard("face", || builder::try_attach_plane(wires).map_err(|e| KernelError::Invalid(format!("profile: {e}"))))
|
||
}
|
||
|
||
fn region_ok(r: &Region2) -> Result<()> {
|
||
if r.area().abs() < 1e-9 {
|
||
return Err(KernelError::Invalid("profile has no area".into()));
|
||
}
|
||
for s in std::iter::once(&r.outer).chain(&r.holes).flat_map(|l| &l.segs) {
|
||
let ok = match *s {
|
||
Seg2::Line { a, b } => a.is_finite() && b.is_finite(),
|
||
Seg2::Arc { center, radius, start, sweep } => {
|
||
center.is_finite() && radius.is_finite() && radius > 1e-9 && start.is_finite() && sweep.is_finite()
|
||
}
|
||
Seg2::Cubic { p0, p1, p2, p3 } => p0.is_finite() && p1.is_finite() && p2.is_finite() && p3.is_finite(),
|
||
Seg2::Conic { a, apex, b, w } => a.is_finite() && apex.is_finite() && b.is_finite() && w.is_finite() && w > 1e-9,
|
||
Seg2::Bezier { n, p } => (4..=solidcraft_geom::MAX_BEZIER_DEGREE as u8).contains(&n) && p.iter().all(|q| q.is_finite()),
|
||
};
|
||
if !ok {
|
||
return Err(KernelError::Invalid("non-finite profile".into()));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 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 {
|
||
return Err(KernelError::Invalid("extrude distance is zero".into()));
|
||
}
|
||
if regions.is_empty() {
|
||
return Err(KernelError::Invalid("no profile selected".into()));
|
||
}
|
||
let n = plane.normal();
|
||
let mut out = Vec::new();
|
||
for r in regions {
|
||
region_ok(r)?;
|
||
let base = plane.offset(lo);
|
||
let profile = region_wires(&base, &oriented(r)?)?;
|
||
let wires = wires_of(&profile);
|
||
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 body = Body::new(solid)?;
|
||
// tsweep retains each actual input boundary edge on its generated side: one pass over
|
||
// the faces matches topology identity (never face order, surface samples or
|
||
// centroids) for both the face sources and the history. The profile face is the start
|
||
// cap, the one face holding no profile edge the end cap.
|
||
let (sources, history) = guard("extrude source identities", || {
|
||
let by_edge: std::collections::HashMap<mt::EdgeID, &ProfileEdge> =
|
||
profile.iter().flat_map(|p| &p.edges).map(|e| (e.edge.id(), e)).collect();
|
||
let mut faces: Vec<(mt::FaceID, Vec<&ProfileEdge>)> = Vec::new();
|
||
let mut visits = 0usize;
|
||
for generated in body.solid.face_iter() {
|
||
let mut matching = Vec::new();
|
||
for edge in generated.absolute_boundaries().iter().flatten() {
|
||
visits += 1;
|
||
if let Some(e) = by_edge.get(&edge.id()) {
|
||
matching.push(*e);
|
||
}
|
||
}
|
||
faces.push((generated.id(), matching));
|
||
}
|
||
let mut ends = faces.iter().filter(|(id, matching)| *id != f.id() && matching.is_empty()).map(|(id, _)| *id);
|
||
let end = match (ends.next(), ends.next()) {
|
||
(Some(id), None) => Some(id),
|
||
_ => None,
|
||
};
|
||
let upper = end.filter(|_| faces.len() <= 50_000 && visits <= 200_000);
|
||
let small = r.outer.segs.len() + r.holes.iter().map(|hole| hole.segs.len()).sum::<usize>() <= 4096;
|
||
let mut sources = Vec::with_capacity(faces.len());
|
||
let mut history = Some(Vec::with_capacity(faces.len()));
|
||
for (id, matching) in &faces {
|
||
let (source, prov) = if *id == f.id() {
|
||
let source = upper.and_then(|_| {
|
||
small.then(|| crate::FaceSource::ExtrudeCap {
|
||
high: false,
|
||
region: r.clone(),
|
||
plane: Plane { origin: base.origin, x: plane.x, y: -plane.y },
|
||
})
|
||
});
|
||
(source, Some(record::cap(false)))
|
||
} else if Some(*id) == upper {
|
||
(small.then(|| crate::FaceSource::ExtrudeCap { high: true, region: r.clone(), plane: plane.offset(hi) }), Some(record::cap(true)))
|
||
} else if let [side] = matching.as_slice() {
|
||
let basis = match side.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: side.segment, plane: basis, coalesced: false }),
|
||
Some(record::profile(&side.numbers)),
|
||
)
|
||
} else if Some(*id) == end {
|
||
(None, Some(record::cap(true)))
|
||
} else {
|
||
(None, None)
|
||
};
|
||
sources.push(source);
|
||
history = history.zip(prov).map(|(mut h, p)| {
|
||
h.push(p);
|
||
h
|
||
});
|
||
}
|
||
Ok((sources, history))
|
||
})?;
|
||
let body = body.with_face_sources(Some(std::sync::Arc::new(sources)));
|
||
out.push(record::attach(body, &[], history, Default::default())?);
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// 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 {
|
||
return Err(KernelError::Invalid("revolve angle is zero".into()));
|
||
}
|
||
let d = axis_dir.normalized().ok_or_else(|| KernelError::Invalid("revolve axis has no direction".into()))?;
|
||
if regions.is_empty() {
|
||
return Err(KernelError::Invalid("no profile selected".into()));
|
||
}
|
||
// The profile must lie on one side of the axis.
|
||
let o = plane.to_world(axis_origin);
|
||
let axis = plane.dir_to_world(d);
|
||
let mut out = Vec::new();
|
||
for r in regions {
|
||
region_ok(r)?;
|
||
let (mut neg, mut pos) = (false, false);
|
||
for p in r.outer.polyline(1e-3) {
|
||
let s = d.cross(p - axis_origin);
|
||
neg |= s < -1e-7;
|
||
pos |= s > 1e-7;
|
||
}
|
||
if neg && pos {
|
||
return Err(KernelError::Invalid("the profile crosses the revolve axis".into()));
|
||
}
|
||
// Points on the axis (within solver tolerance) go exactly onto it, so the sweep does not
|
||
// create hair-thin faces.
|
||
let snap = |p: Vec2| {
|
||
let along = (p - axis_origin).dot(d);
|
||
let q = axis_origin + d * along;
|
||
if p.dist(q) < 1e-7 { q } else { p }
|
||
};
|
||
let snap_loop = |l: &NumberedLoop| {
|
||
l.map_segs(|s| match *s {
|
||
Seg2::Line { a, b } => Seg2::Line { a: snap(a), b: snap(b) },
|
||
arc => arc,
|
||
})
|
||
};
|
||
// Full circles start at an odd angle: their seam points then don't lie in the planes
|
||
// through the profile's centre that other bodies tend to cut along. Each new arc comes
|
||
// from every given arc it overlaps.
|
||
let reseam = |nl: NumberedLoop| -> NumberedLoop {
|
||
let l = &nl.lp;
|
||
let c = match l.segs.first() {
|
||
Some(Seg2::Arc { center, radius, .. }) => Some((*center, *radius)),
|
||
_ => None,
|
||
};
|
||
// (A circle touching the axis keeps its vertex there.)
|
||
let touches = c.is_some_and(|(center, radius)| (d.cross(center - axis_origin).abs() - radius).abs() < 1e-7);
|
||
match c {
|
||
Some((center, radius))
|
||
if !touches
|
||
&& l.segs.iter().all(
|
||
|s| matches!(s, Seg2::Arc { center: c2, radius: r2, .. } if c2.dist(center) < 1e-9 && (r2 - radius).abs() < 1e-9),
|
||
)
|
||
&& (l
|
||
.segs
|
||
.iter()
|
||
.map(|s| match s {
|
||
Seg2::Arc { sweep, .. } => *sweep,
|
||
_ => 0.0,
|
||
})
|
||
.sum::<f64>()
|
||
.abs()
|
||
- std::f64::consts::TAU)
|
||
.abs()
|
||
< 1e-6 =>
|
||
{
|
||
let ccw = l.signed_area() > 0.0;
|
||
let n = Loop2::circle_from(center, radius, 0.4142);
|
||
nl.resplit_circle(if ccw { n } else { n.reversed() })
|
||
}
|
||
_ => nl,
|
||
}
|
||
};
|
||
let given = numbered_region(r)?.map_loops(|l| reseam(snap_loop(l)));
|
||
let r = &given.region();
|
||
// A half disc on the axis revolved fully is a sphere: build it without poles. Every
|
||
// patch is swept from the half disc's arcs.
|
||
if angle.abs() >= std::f64::consts::TAU - 1e-9
|
||
&& r.holes.is_empty()
|
||
&& let Some((c, rad)) = half_disc(&r.outer, axis_origin, d)
|
||
{
|
||
let arcs: Vec<usize> = r
|
||
.outer
|
||
.segs
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(_, s)| matches!(s, Seg2::Arc { .. }))
|
||
.flat_map(|(i, _)| given.outer.numbers(i).to_vec())
|
||
.collect();
|
||
out.push(sphere_solid(plane.to_world(c), rad, [Vec3::X, Vec3::Y, Vec3::Z], &arcs)?);
|
||
continue;
|
||
}
|
||
// A full revolution of a profile with one edge on the axis: sweep the rest of the loop
|
||
// as a cone-like shell (no zero-area faces, which booleans can't handle).
|
||
if angle.abs() >= std::f64::consts::TAU - 1e-9
|
||
&& r.holes.is_empty()
|
||
&& let Some(b) = revolve_touching_axis(plane, &given.outer, axis_origin, d)?
|
||
{
|
||
out.push(b);
|
||
continue;
|
||
}
|
||
let profile = region_wires(plane, &given.oriented())?;
|
||
let f = attach_profile(&wires_of(&profile))?;
|
||
// Partial revolves sweep toward the side the sketch normal points to (as features
|
||
// extrude toward it).
|
||
let c = plane.to_world(r.centroid());
|
||
let radial = (c - o) - axis * (c - o).dot(axis);
|
||
let toward = axis.cross(radial).dot(plane.normal());
|
||
let angle = if toward < 0.0 { -angle } else { angle };
|
||
// A profile with an edge on the axis: no zero-area face where that edge turns on itself.
|
||
if r.holes.is_empty()
|
||
&& let Some(b) = revolve_partial_on_axis(plane, &given.outer, axis_origin, d, angle)?
|
||
{
|
||
out.push(b);
|
||
continue;
|
||
}
|
||
let solid: Solid = guard("revolve", || Ok(builder::rsweep(&f, p3(o), v3(axis), mt::Rad(angle))))?;
|
||
let closed = angle.abs() >= std::f64::consts::TAU;
|
||
let history = guard("revolve history", || Ok(record::swept_face(&solid, &f, &seeds(&profile), closed)))?;
|
||
out.push(record::attach(Body::new(solid)?, &[], history, Default::default())?);
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// Axis-aligned box between two corners.
|
||
pub fn box_solid(a: Vec3, b: Vec3) -> Result<Body> {
|
||
let (lo, hi) = (a.min(b), a.max(b));
|
||
let s = hi - lo;
|
||
if !(s.x > 1e-6 && s.y > 1e-6 && s.z > 1e-6 && s.is_finite() && s.len() < 1e7) {
|
||
return Err(KernelError::Invalid("box needs a positive size in x, y and z".into()));
|
||
}
|
||
let r = Region2 {
|
||
outer: Loop2::polygon(&[Vec2::new(lo.x, lo.y), Vec2::new(hi.x, lo.y), Vec2::new(hi.x, hi.y), Vec2::new(lo.x, hi.y)]),
|
||
holes: vec![],
|
||
};
|
||
let mut v = extrude(&Plane::XY, &[r], lo.z, hi.z)?;
|
||
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).
|
||
pub(crate) const SEAM_ANGLE: f64 = 0.6131;
|
||
|
||
/// Cylinder with base centre `base`, axis direction `axis`, radius and height.
|
||
pub fn cylinder(base: Vec3, axis: Vec3, radius: f64, height: f64) -> Result<Body> {
|
||
let plane = Plane::from_normal(base, axis).ok_or_else(|| KernelError::Invalid("cylinder axis".into()))?;
|
||
let radius = finite(radius, "radius")?;
|
||
if radius <= 1e-6 {
|
||
return Err(KernelError::Invalid("radius must be positive".into()));
|
||
}
|
||
// The seams at an odd angle: on the frame's axes they would lie exactly where faces placed
|
||
// on a grid cut the cylinder (along a seam edge, which booleans don't survive).
|
||
let r = Region2 { outer: Loop2::circle_from(Vec2::ZERO, radius, SEAM_ANGLE), holes: vec![] };
|
||
let mut v = extrude(&plane, &[r], 0.0, finite(height, "height")?)?;
|
||
v.pop().ok_or_else(|| KernelError::Failed("cylinder".into()))
|
||
}
|
||
|
||
/// Sphere: revolve a half disc.
|
||
pub fn sphere(center: Vec3, radius: f64) -> Result<Body> {
|
||
sphere_patches(center, radius)
|
||
}
|
||
|
||
/// Torus about the Z axis through `center`.
|
||
pub fn torus(center: Vec3, major: f64, minor: f64) -> Result<Body> {
|
||
let (major, minor) = (finite(major, "major radius")?, finite(minor, "minor radius")?);
|
||
if !(minor > 1e-6 && major > minor) {
|
||
return Err(KernelError::Invalid("torus needs major > minor > 0".into()));
|
||
}
|
||
let plane = Plane { origin: center, ..Plane::XZ };
|
||
let r = Region2 { outer: Loop2::circle(Vec2::new(major, 0.0), minor), holes: vec![] };
|
||
let mut v = revolve(&plane, &[r], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)?;
|
||
v.pop().ok_or_else(|| KernelError::Failed("torus".into()))
|
||
}
|
||
|
||
/// Offset every segment of a loop to its right by `d` (outward for a region's outer loop and
|
||
/// holes alike). Loops of lines (mitred corners) and loops of arcs on one circle are supported.
|
||
fn offset_loop(lp: &Loop2, d: f64) -> Result<Loop2> {
|
||
if d == 0.0 {
|
||
return Ok(lp.clone());
|
||
}
|
||
let all_arcs = lp.segs.iter().all(|s| matches!(s, Seg2::Arc { .. }));
|
||
if all_arcs {
|
||
let segs = lp
|
||
.segs
|
||
.iter()
|
||
.map(|s| match *s {
|
||
Seg2::Arc { center, radius, start, sweep } => {
|
||
let r = radius + sweep.signum() * d;
|
||
if r > 1e-6 {
|
||
Ok(Seg2::Arc { center, radius: r, start, sweep })
|
||
} else {
|
||
Err(KernelError::Invalid("the taper closes the profile".into()))
|
||
}
|
||
}
|
||
_ => Err(KernelError::Failed("not supported yet: tapered profiles mixing lines and arcs".into())),
|
||
})
|
||
.collect::<Result<Vec<_>>>()?;
|
||
return Ok(Loop2 { segs });
|
||
}
|
||
let n = lp.segs.len();
|
||
let mut lines: Vec<(Vec2, Vec2)> = Vec::with_capacity(n);
|
||
for s in &lp.segs {
|
||
let Seg2::Line { a, b } = *s else { return Err(KernelError::Failed("not supported yet: tapered profiles mixing lines and arcs".into())) };
|
||
let dir = (b - a).normalized().ok_or_else(|| KernelError::Invalid("zero-length edge".into()))?;
|
||
let right = Vec2::new(dir.y, -dir.x);
|
||
lines.push((a + right * d, dir));
|
||
}
|
||
let mut pts = Vec::with_capacity(n);
|
||
for i in 0..n {
|
||
let (Some(&(p0, d0)), Some(&(p1, d1))) = (lines.get((i + n - 1) % n), lines.get(i)) else { continue };
|
||
let den = d0.cross(d1);
|
||
if den.abs() < 1e-12 {
|
||
pts.push(p1);
|
||
continue;
|
||
}
|
||
let t = (p1 - p0).cross(d1) / den;
|
||
pts.push(p0 + d0 * t);
|
||
}
|
||
let out = Loop2::polygon(&pts);
|
||
if out.signed_area().signum() != lp.signed_area().signum() || out.signed_area().abs() < 1e-9 {
|
||
return Err(KernelError::Invalid("the taper closes the profile".into()));
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// Extrude one region by `length` along `dir_sign` × the plane normal with a taper angle
|
||
/// (radians; positive grows the profile).
|
||
pub fn extrude_tapered(plane: &Plane, region: &Region2, length: f64, dir_sign: f64, taper: f64) -> Result<Body> {
|
||
let (length, taper) = (finite(length, "distance")?, finite(taper, "taper")?);
|
||
if length <= 1e-6 {
|
||
return Err(KernelError::Invalid("extrude distance is zero".into()));
|
||
}
|
||
if taper.abs() >= std::f64::consts::FRAC_PI_2 - 1e-3 {
|
||
return Err(KernelError::Invalid("taper must be between −90° and 90°".into()));
|
||
}
|
||
region_ok(region)?;
|
||
let d = length * taper.tan();
|
||
let top_plane = plane.offset(length * dir_sign.signum());
|
||
let loops0 = oriented(region)?;
|
||
let loops1: Vec<Loop2> = loops0.loops().map(|l| offset_loop(&l.lp, d)).collect::<Result<_>>()?;
|
||
guard("tapered extrude", || {
|
||
let p0 = region_wires(plane, &loops0)?;
|
||
let w0 = wires_of(&p0);
|
||
let w1: Vec<mt::Wire> = loops1.iter().map(|l| wire(&top_plane, l)).collect::<Result<_>>()?;
|
||
let mut sides: Vec<Side> = Vec::new();
|
||
for (a, b) in p0.iter().zip(&w1) {
|
||
let shell = builder::try_wire_homotopy(&a.wire, b).map_err(|e| KernelError::Failed(format!("taper sides: {e}")))?;
|
||
// The offset loop keeps the loop's segments, edge for edge.
|
||
let numbers = a.numbers();
|
||
sides.extend(record::ruled(&shell, &a.wire, b, &numbers, &numbers));
|
||
}
|
||
let (faces, prov): (Vec<mt::Face>, Vec<Option<FaceProv>>) = sides.into_iter().unzip();
|
||
// The sides, then the bottom and top as pushed below.
|
||
let history: Option<Vec<FaceProv>> = prov.into_iter().chain([Some(record::cap(false)), Some(record::cap(true))]).collect();
|
||
let bottom = builder::try_attach_plane(&w0.iter().map(|w| w.inverse()).collect::<Vec<_>>())
|
||
.map_err(|e| KernelError::Failed(format!("taper bottom: {e}")))?;
|
||
let top = builder::try_attach_plane(&w1).map_err(|e| KernelError::Failed(format!("taper top: {e}")))?;
|
||
let mut tries: Vec<(mt::Face, mt::Face)> = vec![(bottom.clone(), top.clone())];
|
||
tries.push((bottom.inverse(), top.inverse()));
|
||
let mut last = String::new();
|
||
for (b, t) in tries {
|
||
let mut all = faces.clone();
|
||
all.push(b);
|
||
all.push(t);
|
||
let shell: mt::Shell = all.into();
|
||
match Solid::try_new(vec![shell]) {
|
||
Ok(s) => return record::attach(Body::new(s)?, &[], history, Default::default()),
|
||
Err(e) => last = e.to_string(),
|
||
}
|
||
}
|
||
Err(KernelError::Failed(format!("tapered extrude: {last}")))
|
||
})
|
||
}
|
||
|
||
/// Revolve 360° a loop that has exactly one line segment on the axis, without degenerate faces.
|
||
/// A loop made of a half circle and its diameter on the axis: (centre, radius).
|
||
fn half_disc(lp: &Loop2, axis_origin: Vec2, d: Vec2) -> Option<(Vec2, f64)> {
|
||
let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-7 * (1.0 + p.len());
|
||
let mut arcs = Vec::new();
|
||
for s in &lp.segs {
|
||
match *s {
|
||
Seg2::Line { a, b } if on_axis(a) && on_axis(b) => {}
|
||
Seg2::Arc { center, radius, sweep, .. } if on_axis(center) => arcs.push((center, radius, sweep)),
|
||
_ => return None,
|
||
}
|
||
}
|
||
let (c, r, _) = *arcs.first()?;
|
||
let total: f64 = arcs.iter().map(|a| a.2.abs()).sum();
|
||
let same = arcs.iter().all(|(c2, r2, _)| c2.dist(c) < 1e-7 * (1.0 + r) && (r2 - r).abs() < 1e-7 * (1.0 + r));
|
||
(same && (total - std::f64::consts::PI).abs() < 1e-6).then_some((c, r))
|
||
}
|
||
|
||
/// A partial revolve of a loop with one edge on the axis: the other edges sweep their faces,
|
||
/// the two caps (the profile at the start and at the end) share the axis edge. `None` when the
|
||
/// loop isn't like that.
|
||
fn revolve_partial_on_axis(plane: &Plane, numbered: &NumberedLoop, axis_origin: Vec2, d: Vec2, angle: f64) -> Result<Option<Body>> {
|
||
use mt::{BoundedCurve, Invertible, ParametricCurve, ParametricSurface3D, SearchNearestParameter, Transformed};
|
||
let lp = &numbered.lp;
|
||
let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9;
|
||
let n = lp.segs.len();
|
||
let axis_segs: Vec<usize> =
|
||
(0..n).filter(|i| lp.segs.get(*i).is_some_and(|s| matches!(s, Seg2::Line { a, b } if on_axis(*a) && on_axis(*b)))).collect();
|
||
let [k] = axis_segs[..] else { return Ok(None) };
|
||
// The open chain after the axis segment, back around to it: only its ends on the axis.
|
||
let chain: Vec<Seg2> = (1..n).filter_map(|j| lp.segs.get((k + j) % n).copied()).collect();
|
||
if chain.is_empty() || chain.iter().skip(1).any(|s| on_axis(s.start())) || chain.iter().any(|s| on_axis(s.mid())) {
|
||
return Ok(None);
|
||
}
|
||
// Built with the axis through the world origin, then moved into place.
|
||
let shift = plane.to_world(axis_origin);
|
||
let local = Plane { origin: plane.origin - shift, ..*plane };
|
||
let plane = &local;
|
||
let axis = plane.dir_to_world(d);
|
||
let rot = mt::Matrix4::from_axis_angle(v3(axis), mt::Rad(angle));
|
||
let half = mt::Matrix4::from_axis_angle(v3(axis), mt::Rad(angle * 0.5));
|
||
let turn = |m: &mt::Matrix4, p: Vec3| from_p3(mt::EuclideanSpace::from_vec((m * p3(p).to_homogeneous()).truncate()));
|
||
guard("revolve", || {
|
||
let fail = |m: &str| KernelError::Failed(format!("revolve: {m}"));
|
||
let pts: Vec<Vec3> = chain.iter().map(|s| plane.to_world(s.start())).chain(chain.last().map(|s| plane.to_world(s.end()))).collect();
|
||
let last = pts.len() - 1;
|
||
let pv: Vec<mt::Vertex> = pts.iter().map(|p| builder::vertex(p3(*p))).collect();
|
||
// The ends stay where they are (on the axis).
|
||
let qv: Vec<mt::Vertex> = pts
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, p)| {
|
||
if i == 0 || i == last { pv.get(i).cloned().unwrap_or_else(|| builder::vertex(p3(*p))) } else { builder::vertex(p3(turn(&rot, *p))) }
|
||
})
|
||
.collect();
|
||
let mut e: Vec<mt::Edge> = Vec::new();
|
||
let mut er: Vec<mt::Edge> = Vec::new();
|
||
for (i, sg) in chain.iter().enumerate() {
|
||
let (Some(a), Some(b), Some(qa), Some(qb)) = (pv.get(i), pv.get(i + 1), qv.get(i), qv.get(i + 1)) else { return Err(fail("chain")) };
|
||
let edge = match *sg {
|
||
Seg2::Line { .. } => builder::line(a, b),
|
||
Seg2::Arc { .. } => builder::circle_arc(a, b, p3(plane.to_world(sg.mid()))),
|
||
Seg2::Cubic { .. } | Seg2::Conic { .. } | Seg2::Bezier { .. } => {
|
||
crate::freeform::edge(plane, sg, a, b).unwrap_or_else(|| builder::line(a, b))
|
||
}
|
||
};
|
||
let curve = edge.oriented_curve().transformed(rot);
|
||
er.push(mt::Edge::new(qa, qb, curve));
|
||
e.push(edge);
|
||
}
|
||
// Arcs swept by the vertices off the axis.
|
||
let mut arcs: Vec<Option<mt::Edge>> = vec![None; pts.len()];
|
||
for i in 1..last {
|
||
let (Some(a), Some(b), Some(p)) = (pv.get(i), qv.get(i), pts.get(i)) else { continue };
|
||
if let Some(slot) = arcs.get_mut(i) {
|
||
*slot = Some(builder::circle_arc(a, b, p3(turn(&half, *p))));
|
||
}
|
||
}
|
||
// Side faces: each edge's swept surface, oriented to agree with its boundary, from the
|
||
// profile segment the edge was built from.
|
||
let mut faces: Vec<(mt::Face, FaceProv)> = Vec::new();
|
||
for (i, sg) in chain.iter().enumerate() {
|
||
let (Some(ei), Some(eri)) = (e.get(i), er.get(i)) else { return Err(fail("edges")) };
|
||
let mut ws: Vec<mt::Edge> = vec![ei.clone()];
|
||
if let Some(Some(c)) = arcs.get(i + 1) {
|
||
ws.push(c.clone());
|
||
}
|
||
ws.push(eri.inverse());
|
||
if let Some(Some(c)) = arcs.get(i) {
|
||
ws.push(c.inverse());
|
||
}
|
||
let wire: mt::Wire = ws.into();
|
||
let radial = matches!(*sg, Seg2::Line { a, b } if (b - a).normalized().is_some_and(|u| u.dot(d).abs() < 1e-9));
|
||
let f = if radial {
|
||
builder::try_attach_plane(&[wire]).map_err(|er| fail(&er.to_string()))?
|
||
} else {
|
||
let template = builder::rsweep(ei, mt::Point3::new(0.0, 0.0, 0.0), v3(axis), mt::Rad(angle));
|
||
let mut surface = template.face_iter().next().map(|f| f.oriented_surface()).ok_or_else(|| fail("swept face"))?;
|
||
// The loop's turning (Newell) against the surface normal halfway round.
|
||
let mut nw = Vec3::ZERO;
|
||
let poly: Vec<Vec3> = wire
|
||
.edge_iter()
|
||
.flat_map(|ed| {
|
||
let c = ed.oriented_curve();
|
||
let (t0, t1) = c.range_tuple();
|
||
(0..8).map(move |j| from_p3(c.subs(t0 + (t1 - t0) * j as f64 / 8.0))).collect::<Vec<_>>()
|
||
})
|
||
.collect();
|
||
for (j, p) in poly.iter().enumerate() {
|
||
if let Some(q) = poly.get((j + 1) % poly.len()) {
|
||
nw += p.cross(*q);
|
||
}
|
||
}
|
||
let probe = turn(&half, plane.to_world(sg.mid()));
|
||
let sn = surface.search_nearest_parameter(p3(probe), None, 100).map(|(u, v)| surface.normal(u, v));
|
||
if sn.is_some_and(|m| nw.dot(Vec3::new(m.x, m.y, m.z)) < 0.0) {
|
||
surface = surface.inverse();
|
||
}
|
||
mt::Face::try_new(vec![wire], surface).map_err(|er| fail(&er.to_string()))?
|
||
};
|
||
faces.push((f, record::profile(numbered.numbers((k + 1 + i) % n))));
|
||
}
|
||
// Caps: the profile at the start and at the end, sharing the axis edge.
|
||
let (Some(a0), Some(an)) = (pv.first(), pv.get(last)) else { return Err(fail("ends")) };
|
||
let axis_edge = builder::line(an, a0);
|
||
let start: mt::Wire = e.iter().rev().map(|x| x.inverse()).chain(std::iter::once(axis_edge.inverse())).collect::<Vec<_>>().into();
|
||
let end: mt::Wire = er.iter().cloned().chain(std::iter::once(axis_edge.clone())).collect::<Vec<_>>().into();
|
||
let cs = builder::try_attach_plane(&[start]).map_err(|er| fail(&er.to_string()))?;
|
||
let ce = builder::try_attach_plane(&[end]).map_err(|er| fail(&er.to_string()))?;
|
||
let (faces, mut history): (Vec<mt::Face>, Vec<FaceProv>) = faces.into_iter().unzip();
|
||
history.extend([record::cap(false), record::cap(true)]);
|
||
let mut last_err = String::new();
|
||
for flip in 0..4 {
|
||
let mut all = faces.clone();
|
||
all.push(if flip & 1 == 0 { cs.clone() } else { cs.inverse() });
|
||
all.push(if flip & 2 == 0 { ce.clone() } else { ce.inverse() });
|
||
match Solid::try_new(vec![all.into()]) {
|
||
Ok(solid) => {
|
||
let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid };
|
||
return record::attach(Body::new(solid)?, &[], Some(history), Default::default()).map(Some);
|
||
}
|
||
Err(er) => last_err = er.to_string(),
|
||
}
|
||
}
|
||
Err(fail(&last_err))
|
||
})
|
||
}
|
||
|
||
fn revolve_touching_axis(plane: &Plane, numbered: &NumberedLoop, axis_origin: Vec2, d: Vec2) -> Result<Option<Body>> {
|
||
let lp = &numbered.lp;
|
||
let on_axis = |p: Vec2| d.cross(p - axis_origin).abs() < 1e-9;
|
||
let n = lp.segs.len();
|
||
let axis_segs: Vec<usize> =
|
||
(0..n).filter(|i| lp.segs.get(*i).is_some_and(|s| matches!(s, Seg2::Line { a, b } if on_axis(*a) && on_axis(*b)))).collect();
|
||
let [k] = axis_segs[..] else { return Ok(None) };
|
||
// The open chain after the axis segment, back around to it, with the profile segments
|
||
// under each piece.
|
||
let mut chain: Vec<Seg2> = (1..n).filter_map(|j| lp.segs.get((k + j) % n).copied()).collect();
|
||
let mut chain_src: Vec<&[usize]> = (1..n).map(|j| numbered.numbers((k + j) % n)).collect();
|
||
// A single arc (a sphere) is split off-centre: a seam on the equator would lie in the very
|
||
// planes other bodies are often cut with.
|
||
if let [Seg2::Arc { center, radius, start, sweep }] = chain[..] {
|
||
let f = 0.37;
|
||
chain = vec![
|
||
Seg2::Arc { center, radius, start, sweep: sweep * f },
|
||
Seg2::Arc { center, radius, start: start + sweep * f, sweep: sweep * (1.0 - f) },
|
||
];
|
||
chain_src = vec![numbered.numbers((k + 1) % n); 2];
|
||
}
|
||
if chain.is_empty() || chain.iter().skip(1).take(chain.len().saturating_sub(2)).any(|s| on_axis(s.start()) && on_axis(s.end())) {
|
||
return Ok(None);
|
||
}
|
||
// Built with the axis through the world origin (truck tessellates such cones reliably), then
|
||
// moved into place.
|
||
let shift = plane.to_world(axis_origin);
|
||
let local = Plane { origin: plane.origin - shift, ..*plane };
|
||
let plane = &local;
|
||
// Radial lines at the ends become planar caps (no degenerate disc centres).
|
||
let radial = |sg: &Seg2| matches!(*sg, Seg2::Line { a, b } if (b - a).normalized().is_some_and(|u| u.dot(d).abs() < 1e-9));
|
||
let mut mid: &[Seg2] = &chain;
|
||
let mut mid_src: &[&[usize]] = &chain_src;
|
||
// The segments of the radial lines that become caps.
|
||
let (mut start_cap, mut end_cap) = (None, None);
|
||
if mid.first().is_some_and(|f| radial(f) && on_axis(f.start())) {
|
||
mid = mid.get(1..).unwrap_or(&[]);
|
||
start_cap = mid_src.first().copied();
|
||
mid_src = mid_src.get(1..).unwrap_or(&[]);
|
||
}
|
||
if mid.last().is_some_and(|l| radial(l) && on_axis(l.end())) {
|
||
mid = mid.get(..mid.len().saturating_sub(1)).unwrap_or(&[]);
|
||
end_cap = mid_src.last().copied();
|
||
mid_src = mid_src.get(..mid_src.len().saturating_sub(1)).unwrap_or(&[]);
|
||
}
|
||
if mid.is_empty() {
|
||
return Ok(None);
|
||
}
|
||
let apex_start = mid.first().is_some_and(|f| on_axis(f.start()));
|
||
let apex_end = mid.last().is_some_and(|l| on_axis(l.end()));
|
||
guard("revolve", || {
|
||
let verts: Vec<mt::Vertex> = mid
|
||
.iter()
|
||
.map(|s| builder::vertex(p3(plane.to_world(s.start()))))
|
||
.chain(mid.last().map(|s| builder::vertex(p3(plane.to_world(s.end())))))
|
||
.collect();
|
||
let mut edges: Vec<mt::Edge> = Vec::new();
|
||
for (i, s) in mid.iter().enumerate() {
|
||
let (Some(a), Some(b)) = (verts.get(i), verts.get(i + 1)) else { continue };
|
||
edges.push(match *s {
|
||
Seg2::Line { .. } => builder::line(a, b),
|
||
Seg2::Arc { .. } => builder::circle_arc(a, b, p3(plane.to_world(s.mid()))),
|
||
Seg2::Cubic { .. } | Seg2::Conic { .. } | Seg2::Bezier { .. } => match crate::freeform::edge(plane, s, a, b) {
|
||
Some(e) => e,
|
||
None => builder::line(a, b),
|
||
},
|
||
});
|
||
}
|
||
// Each edge with the profile segments under it, before the wire may be turned round.
|
||
let seeds: Vec<(mt::EdgeID, Vec<usize>)> = edges.iter().map(|e| e.id()).zip(mid_src.iter().map(|s| s.to_vec())).collect();
|
||
let mut w: mt::Wire = edges.into();
|
||
let axis = plane.dir_to_world(d);
|
||
let mut shell = if apex_start || apex_end {
|
||
if !apex_start {
|
||
// builder::cone wants the apex first.
|
||
w = w.inverse();
|
||
}
|
||
builder::cone(&w, v3(axis), mt::Rad(std::f64::consts::TAU))
|
||
} else {
|
||
builder::rsweep(&w, p3(plane.to_world(axis_origin)), v3(axis), mt::Rad(std::f64::consts::TAU))
|
||
};
|
||
let sides = shell.len();
|
||
// History. truck sweeps each wire edge round into a column of faces (triangles at an
|
||
// apex, which stays put); each cap is the disc a trimmed radial line sweeps, found by
|
||
// the profile vertex on its rim.
|
||
let fixed: Vec<mt::VertexID> =
|
||
[(apex_start, verts.first()), (apex_end, verts.last())].into_iter().filter_map(|(apex, v)| v.filter(|_| apex).map(|v| v.id())).collect();
|
||
let mut history: Option<Vec<FaceProv>> = (seeds.len() == mid.len())
|
||
.then(|| record::columns(shell.face_iter(), &seeds, None, record::Column::ToEnd, &fixed))
|
||
.flatten()
|
||
.and_then(|sides| sides.into_iter().collect());
|
||
let (first_vertex, last_vertex) = (verts.first().map(|v| v.id()), verts.last().map(|v| v.id()));
|
||
for b in shell.extract_boundaries() {
|
||
let at = |v: Option<mt::VertexID>| v.is_some_and(|v| b.vertex_iter().any(|x| x.id() == v));
|
||
let src = if at(first_vertex) {
|
||
start_cap
|
||
} else if at(last_vertex) {
|
||
end_cap
|
||
} else {
|
||
None
|
||
};
|
||
history = history.zip(src).map(|(mut h, numbers)| {
|
||
h.push(record::profile(numbers));
|
||
h
|
||
});
|
||
let cap = builder::try_attach_plane(&[b]).map_err(|e| KernelError::Failed(format!("revolve cap: {e}")))?;
|
||
shell.push(cap);
|
||
}
|
||
let solid = Solid::try_new(vec![shell.clone()]).or_else(|_| {
|
||
// Caps may come out facing inwards: flip them.
|
||
let mut s2 = shell.clone();
|
||
for f in s2.face_iter_mut().skip(sides) {
|
||
f.invert();
|
||
}
|
||
Solid::try_new(vec![s2])
|
||
});
|
||
let solid = solid.map_err(|e| KernelError::Failed(format!("revolve: {e}")))?;
|
||
let solid = if shift.len() > 0.0 { builder::translated(&solid, v3(shift)) } else { solid };
|
||
record::attach(Body::new(solid)?, &[], history, Default::default()).map(Some)
|
||
})
|
||
}
|
||
|
||
/// Close a ruled shell (side faces between matching wires, with their provenance as built)
|
||
/// with planar caps into a body, whose history is the sides' and the caps': the profile at
|
||
/// `w0` is the start cap and at `w1` the end cap (the other way round when `from_end`).
|
||
pub(crate) fn cap_and_close(sides: Vec<Side>, w0: &[mt::Wire], w1: &[mt::Wire], from_end: bool) -> Result<Body> {
|
||
let (mut faces, prov): (Vec<mt::Face>, Vec<Option<FaceProv>>) = sides.into_iter().unzip();
|
||
let history: Option<Vec<FaceProv>> = prov.into_iter().chain([Some(record::cap(from_end)), Some(record::cap(!from_end))]).collect();
|
||
let bottom =
|
||
builder::try_attach_plane(&w0.iter().map(|w| w.inverse()).collect::<Vec<_>>()).map_err(|e| KernelError::Failed(format!("cap: {e}")))?;
|
||
let top = builder::try_attach_plane(w1).map_err(|e| KernelError::Failed(format!("cap: {e}")))?;
|
||
let mut last = String::new();
|
||
for (b, t) in [(bottom.clone(), top.clone()), (bottom.inverse(), top.inverse()), (bottom.clone(), top.inverse()), (bottom.inverse(), top)] {
|
||
let mut all = std::mem::take(&mut faces);
|
||
let keep = all.clone();
|
||
all.push(b);
|
||
all.push(t);
|
||
let shell: mt::Shell = all.into();
|
||
match Solid::try_new(vec![shell]) {
|
||
Ok(s) => return record::attach(Body::new(s)?, &[], history, Default::default()),
|
||
Err(e) => last = e.to_string(),
|
||
}
|
||
faces = keep;
|
||
}
|
||
Err(KernelError::Failed(format!("closing the shell: {last}")))
|
||
}
|
||
|
||
/// Ruled loft through two or more planar sections (outer loops only). The sections and the
|
||
/// same sections in reverse order give the same body.
|
||
pub fn loft(sections: &[(Plane, Loop2)]) -> Result<Body> {
|
||
if sections.len() < 2 || sections.len() > 50 {
|
||
return Err(KernelError::Invalid("a loft needs 2…50 sections".into()));
|
||
}
|
||
let n = sections.iter().map(|(_, l)| l.segs.len()).max().unwrap_or(0);
|
||
if n == 0 || n > MAX_SEGS {
|
||
return Err(KernelError::Invalid("empty section".into()));
|
||
}
|
||
// A canonical order, so that reversing the sections changes nothing: the one whose
|
||
// first-to-last step has a positive largest component. The side faces' parameterisation
|
||
// follows the order, and the boolean worked on one and failed on the other (a cut whose
|
||
// loft touches the body along an edge: an internal panic, or "shell not oriented and
|
||
// closed").
|
||
let centre = |(p, l): &(Plane, Loop2)| p.to_world(l.centroid());
|
||
let (Some(first), Some(last)) = (sections.first(), sections.last()) else { return Err(KernelError::Invalid("no sections".into())) };
|
||
let step = centre(last) - centre(first);
|
||
let big = [step.x, step.y, step.z].into_iter().max_by(|a, b| a.abs().total_cmp(&b.abs())).unwrap_or(0.0);
|
||
let reversed = big < 0.0;
|
||
// The sections' segments are numbered in the order given.
|
||
let mut ordered: Vec<(Plane, NumberedLoop)> =
|
||
sections.iter().map(|(p, _)| *p).zip(numbered::number_loops(sections.iter().map(|(_, l)| l))).collect();
|
||
if reversed {
|
||
ordered.reverse();
|
||
}
|
||
// Orient every section counter-clockwise about the first section's normal (`Body::new`
|
||
// turns the solid outward), then match segment counts and starting points to the section
|
||
// with the most segments.
|
||
let n0 = ordered.first().map(|(p, _)| p.normal()).unwrap_or(Vec3::Z);
|
||
let oriented: Vec<(Plane, NumberedLoop)> = ordered
|
||
.iter()
|
||
.map(|(p, l)| {
|
||
let ccw = p.normal().dot(n0) >= 0.0;
|
||
(*p, l.oriented(!ccw))
|
||
})
|
||
.collect();
|
||
let lead = oriented.iter().max_by_key(|(_, l)| l.lp.segs.len()).ok_or_else(|| KernelError::Invalid("no sections".into()))?;
|
||
let lead_c = lead.0.to_world(lead.1.lp.centroid());
|
||
let lead_dirs: Vec<Vec3> = lead.1.lp.segs.iter().map(|s| lead.0.to_world(s.start()) - lead_c).collect();
|
||
let mut wires: Vec<mt::Wire> = Vec::new();
|
||
// Per section, the profile segments under each of its wire's edges.
|
||
let mut edge_sources: Vec<Vec<Vec<usize>>> = Vec::new();
|
||
for (p, l) in &oriented {
|
||
let c = l.lp.centroid();
|
||
// Break angles: the lead section's vertex directions seen in this plane.
|
||
let angles: Vec<f64> = lead_dirs.iter().map(|d| Vec2::new(d.dot(p.x), d.dot(p.y)).angle()).collect();
|
||
let mut r = l.resample(n, &angles);
|
||
// Start at the vertex closest in direction to the lead's first vertex.
|
||
if let Some(d0) = lead_dirs.first() {
|
||
let target = Vec2::new(d0.dot(p.x), d0.dot(p.y)).angle();
|
||
let ang = |s: &Seg2| {
|
||
let d = ((s.start() - c).angle() - target).rem_euclid(std::f64::consts::TAU);
|
||
d.min(std::f64::consts::TAU - d)
|
||
};
|
||
let best = r.lp.segs.iter().enumerate().min_by(|a, b| ang(a.1).total_cmp(&ang(b.1))).map(|(k, _)| k);
|
||
if let Some(k) = best {
|
||
r.rotate_left(k);
|
||
}
|
||
}
|
||
wires.push(uniform_wire(p, &r.lp)?);
|
||
edge_sources.push(r.from);
|
||
}
|
||
// The wires run from the first section given, or (turned round) the last.
|
||
// Three sections or more: one smooth surface through them all for each segment.
|
||
if wires.len() >= 3
|
||
&& let Ok(b) = guard("loft", || smooth_loft(&wires, &edge_sources, reversed))
|
||
{
|
||
return Ok(b);
|
||
}
|
||
guard("loft", || {
|
||
// Each ruled face lies between two sections: it comes from the section edges it spans.
|
||
let mut sides: Vec<Side> = Vec::new();
|
||
for (w, src) in wires.windows(2).zip(edge_sources.windows(2)) {
|
||
let (Some(a), Some(b), Some(na), Some(nb)) = (w.first(), w.get(1), src.first(), src.get(1)) else { continue };
|
||
let shell = builder::try_wire_homotopy(a, b).map_err(|e| KernelError::Failed(format!("loft: {e}")))?;
|
||
sides.extend(record::ruled(&shell, a, b, na, nb));
|
||
}
|
||
let (Some(first), Some(last)) = (wires.first(), wires.last()) else { return Err(KernelError::Failed("loft".into())) };
|
||
cap_and_close(sides, std::slice::from_ref(first), std::slice::from_ref(last), reversed)
|
||
})
|
||
}
|
||
|
||
/// Points across the sections (one per section, in order) as a smooth B-spline: cubic through
|
||
/// four or more, quadratic through three.
|
||
fn across(pts: &[Vec3]) -> Option<mt::BSplineCurve<mt::Point3>> {
|
||
match pts {
|
||
[a, b, c] => {
|
||
let m = *b * 2.0 - (*a + *c) * 0.5;
|
||
mt::BSplineCurve::try_new(mt::KnotVec::bezier_knot(2), vec![p3(*a), p3(m), p3(*c)]).ok()
|
||
}
|
||
_ => interpolate_cubic(pts),
|
||
}
|
||
}
|
||
|
||
/// A loft whose side faces are B-spline surfaces through every section (smooth across the
|
||
/// middle sections, which leave no edges). Every section wire comes from `uniform_wire`, so the
|
||
/// k-th edges of all sections share one knot vector and their control points pair up. Face k
|
||
/// comes from the profile segments under the k-th edge of every section (`numbers`: by
|
||
/// section, by edge); the caps as [`cap_and_close`] takes them (`from_end`).
|
||
fn smooth_loft(wires: &[mt::Wire], numbers: &[Vec<Vec<usize>>], from_end: bool) -> Result<Body> {
|
||
let fail = |m: &str| KernelError::Failed(format!("smooth loft: {m}"));
|
||
let edges: Vec<Vec<mt::Edge>> = wires.iter().map(|w| w.edge_iter().cloned().collect()).collect();
|
||
let n = edges.first().map(Vec::len).ok_or_else(|| fail("no sections"))?;
|
||
if edges.iter().any(|e| e.len() != n) {
|
||
return Err(fail("sections with different edge counts"));
|
||
}
|
||
let curve_of = |e: &mt::Edge| -> Option<mt::BSplineCurve<mt::Point3>> {
|
||
match e.curve() {
|
||
mt::Curve::BSplineCurve(c) if e.orientation() => Some(c),
|
||
_ => None,
|
||
}
|
||
};
|
||
let (Some(first), Some(last)) = (edges.first(), edges.last()) else { return Err(fail("sections")) };
|
||
// Seams: through the k-th vertex of every section.
|
||
let mut seams: Vec<mt::Edge> = Vec::with_capacity(n);
|
||
for k in 0..n {
|
||
let pts: Vec<Vec3> = edges.iter().filter_map(|es| es.get(k).map(|e| from_p3(e.front().point()))).collect();
|
||
let c = across(&pts).ok_or_else(|| fail("seam"))?;
|
||
let (Some(a), Some(b)) = (first.get(k), last.get(k)) else { return Err(fail("seam")) };
|
||
seams.push(mt::Edge::new(a.front(), b.front(), mt::Curve::BSplineCurve(c)));
|
||
}
|
||
let mut faces = Vec::with_capacity(n);
|
||
for k in 0..n {
|
||
let curves: Vec<mt::BSplineCurve<mt::Point3>> =
|
||
edges.iter().map(|es| es.get(k).and_then(curve_of)).collect::<Option<_>>().ok_or_else(|| fail("section curves"))?;
|
||
let c0 = curves.first().ok_or_else(|| fail("section curves"))?;
|
||
let uk = c0.knot_vec().clone();
|
||
let m = c0.control_points().len();
|
||
if curves.iter().any(|c| c.control_points().len() != m || c.knot_vec() != &uk) {
|
||
return Err(fail("sections parameterised differently"));
|
||
}
|
||
let mut ctrl: Vec<Vec<mt::Point3>> = Vec::with_capacity(m);
|
||
let mut vk = None;
|
||
for j in 0..m {
|
||
let pts: Vec<Vec3> = curves.iter().filter_map(|c| c.control_points().get(j).map(|p| from_p3(*p))).collect();
|
||
let c = across(&pts).ok_or_else(|| fail("surface"))?;
|
||
vk = Some(c.knot_vec().clone());
|
||
ctrl.push(c.control_points().clone());
|
||
}
|
||
let vk = vk.ok_or_else(|| fail("surface"))?;
|
||
let surface = mt::BSplineSurface::try_new((uk, vk), ctrl).map_err(|e| fail(&e.to_string()))?;
|
||
let (Some(e0), Some(e1), Some(s0), Some(s1)) = (first.get(k), last.get(k), seams.get(k), seams.get((k + 1) % n)) else {
|
||
return Err(fail("face"));
|
||
};
|
||
// u along the section edge, v across the sections: counter-clockwise in (u, v).
|
||
let w: mt::Wire = vec![e0.clone(), s1.clone(), e1.inverse(), s0.inverse()].into();
|
||
let face = mt::Face::try_new(vec![w], mt::Surface::BSplineSurface(surface)).map_err(|e| fail(&e.to_string()))?;
|
||
let under: Vec<&usize> = numbers.iter().filter_map(|s| s.get(k)).flatten().collect();
|
||
faces.push((face, (under.len() >= numbers.len()).then(|| record::profile(under))));
|
||
}
|
||
let (Some(w0), Some(w1)) = (wires.first(), wires.last()) else { return Err(fail("sections")) };
|
||
cap_and_close(faces, std::slice::from_ref(w0), std::slice::from_ref(w1), from_end)
|
||
}
|
||
|
||
/// A 3D path segment for sweeps.
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub enum PathSeg {
|
||
Line {
|
||
a: Vec3,
|
||
b: Vec3,
|
||
},
|
||
/// Arc around `center`, rotating by `angle` about `axis` (right hand) from `a`.
|
||
Arc {
|
||
a: Vec3,
|
||
center: Vec3,
|
||
axis: Vec3,
|
||
angle: f64,
|
||
},
|
||
}
|
||
|
||
impl PathSeg {
|
||
/// Unit directions of the path where the segment starts and ends.
|
||
fn tangents(&self) -> Option<(Vec3, Vec3)> {
|
||
match *self {
|
||
PathSeg::Line { a, b } => {
|
||
let t = (b - a).normalized()?;
|
||
Some((t, t))
|
||
}
|
||
PathSeg::Arc { a, center, axis, angle } => {
|
||
let k = axis.normalized()?;
|
||
let end = rotate_about(a, center, k, angle);
|
||
Some((k.cross(a - center).normalized()?, k.cross(end - center).normalized()?))
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Points along the segment (an arc within `tol` of its chords), without its start.
|
||
fn points_after_start(&self, tol: f64) -> Vec<Vec3> {
|
||
match *self {
|
||
PathSeg::Line { b, .. } => vec![b],
|
||
PathSeg::Arc { a, center, axis, angle } => {
|
||
let Some(k) = axis.normalized() else { return vec![a] };
|
||
let r = (a - center).len();
|
||
let step = if r > tol { 2.0 * (1.0 - tol / r).acos() } else { angle.abs() };
|
||
let n = ((angle.abs() / step.max(1e-6)).ceil() as usize).clamp(1, 512);
|
||
(1..=n).map(|i| rotate_about(a, center, k, angle * i as f64 / n as f64)).collect()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// `p` turned by `angle` about the unit axis `k` through `center` (right hand).
|
||
fn rotate_about(p: Vec3, center: Vec3, k: Vec3, angle: f64) -> Vec3 {
|
||
let v = p - center;
|
||
let (s, c) = angle.sin_cos();
|
||
center + v * c + k.cross(v) * s + k * (k.dot(v) * (1.0 - c))
|
||
}
|
||
|
||
/// Sweep a planar region (outer loop) along a chain of path segments, keeping the profile
|
||
/// perpendicular to the path (lines translate it, arcs rotate it). A path with sharp corners
|
||
/// is swept as a polyline, mitred at the corners.
|
||
pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result<Body> {
|
||
region_ok(region)?;
|
||
if path.is_empty() || path.len() > 1000 {
|
||
return Err(KernelError::Invalid("the path needs 1…1000 segments".into()));
|
||
}
|
||
// Segment by segment the profile only follows a path whose direction runs on across each
|
||
// joint: at a corner the next line would slide the profile within its own plane.
|
||
let ends: Vec<(Vec3, Vec3)> =
|
||
path.iter().map(PathSeg::tangents).collect::<Option<_>>().ok_or_else(|| KernelError::Invalid("a path segment has no direction".into()))?;
|
||
let corner = ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos());
|
||
if corner {
|
||
let first = match path.first() {
|
||
Some(PathSeg::Line { a, .. } | PathSeg::Arc { a, .. }) => *a,
|
||
None => return Err(KernelError::Invalid("the path needs 1…1000 segments".into())),
|
||
};
|
||
let mut pts = vec![first];
|
||
for seg in path {
|
||
pts.extend(seg.points_after_start(1e-2));
|
||
}
|
||
return crate::sweep_path::sweep_path(plane, region, &pts);
|
||
}
|
||
// The outer loop and each hole, swept along the path; the ends are capped with holes.
|
||
let profile = region_wires(plane, &oriented(region)?)?;
|
||
let starts = wires_of(&profile);
|
||
guard("sweep", || {
|
||
let mut shell = mt::Shell::new();
|
||
let mut ends = Vec::new();
|
||
for w0 in &starts {
|
||
let mut cur = w0.clone();
|
||
for seg in path {
|
||
let mut part: mt::Shell = match *seg {
|
||
PathSeg::Line { a, b } => builder::tsweep(&cur, v3(b - a)),
|
||
PathSeg::Arc { center, axis, angle, .. } => {
|
||
let ax = axis.normalized().ok_or_else(|| KernelError::Invalid("arc axis".into()))?;
|
||
builder::rsweep(&cur, p3(center), v3(ax), mt::Rad(angle))
|
||
}
|
||
};
|
||
let next = part
|
||
.extract_boundaries()
|
||
.into_iter()
|
||
.find(|w| w.edge_iter().all(|e| !cur.edge_iter().any(|c| c.id() == e.id())))
|
||
.ok_or_else(|| KernelError::Failed("sweep: lost the profile".into()))?;
|
||
shell.append(&mut part);
|
||
cur = next.inverse();
|
||
}
|
||
ends.push(cur);
|
||
}
|
||
// truck sweeps the faces: each profile edge's run along the whole path, path segment
|
||
// after segment.
|
||
let sides: Vec<Side> = match record::columns(shell.face_iter(), &seeds(&profile), None, record::Column::ToEnd, &[]) {
|
||
Some(prov) => shell.face_iter().cloned().zip(prov).collect(),
|
||
None => shell.face_iter().map(|f| (f.clone(), None)).collect(),
|
||
};
|
||
cap_and_close(sides, &starts, &ends, false)
|
||
})
|
||
}
|
||
|
||
/// Cox–de Boor basis values N_{j,3}(u) for all j.
|
||
fn cubic_basis(knots: &[f64], n_ctrl: usize, u: f64) -> Vec<f64> {
|
||
let p = 3;
|
||
let m = knots.len();
|
||
let mut n = vec![0.0; m.saturating_sub(1)];
|
||
let last = n_ctrl.saturating_sub(1);
|
||
for (j, v) in n.iter_mut().enumerate() {
|
||
let (Some(&a), Some(&b)) = (knots.get(j), knots.get(j + 1)) else { continue };
|
||
// Half-open spans, with the last span closed at the end.
|
||
if (a <= u && u < b) || (u >= b && (b - a) > 0.0 && j == last && (u - b).abs() < 1e-12) {
|
||
*v = 1.0;
|
||
}
|
||
}
|
||
for k in 1..=p {
|
||
for j in 0..m.saturating_sub(1 + k) {
|
||
let (Some(&tj), Some(&tjk), Some(&tj1), Some(&tjk1)) = (knots.get(j), knots.get(j + k), knots.get(j + 1), knots.get(j + k + 1)) else {
|
||
continue;
|
||
};
|
||
let left = if tjk - tj > 0.0 { (u - tj) / (tjk - tj) * n.get(j).copied().unwrap_or(0.0) } else { 0.0 };
|
||
let right = if tjk1 - tj1 > 0.0 { (tjk1 - u) / (tjk1 - tj1) * n.get(j + 1).copied().unwrap_or(0.0) } else { 0.0 };
|
||
if let Some(v) = n.get_mut(j) {
|
||
*v = left + right;
|
||
}
|
||
}
|
||
}
|
||
n.truncate(n_ctrl);
|
||
n
|
||
}
|
||
|
||
/// Cubic B-spline through points at uniform parameters (clamped, knots by averaging).
|
||
pub(crate) fn interpolate_cubic(pts: &[Vec3]) -> Option<mt::BSplineCurve<mt::Point3>> {
|
||
let m = pts.len();
|
||
if m < 4 {
|
||
return None;
|
||
}
|
||
let u: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
|
||
let mut knots = vec![0.0; 4];
|
||
for j in 1..(m - 3) {
|
||
knots.push((j..j + 3).filter_map(|i| u.get(i)).sum::<f64>() / 3.0);
|
||
}
|
||
knots.extend([1.0; 4]);
|
||
// Dense solve of N · P = Q for each coordinate.
|
||
let mut a: Vec<Vec<f64>> = u.iter().map(|ui| cubic_basis(&knots, m, *ui)).collect();
|
||
let mut q: Vec<[f64; 3]> = pts.iter().map(|p| [p.x, p.y, p.z]).collect();
|
||
for col in 0..m {
|
||
let piv = (col..m).max_by(|x, y| a[*x][col].abs().total_cmp(&a[*y][col].abs()))?;
|
||
if a[piv][col].abs() < 1e-14 {
|
||
return None;
|
||
}
|
||
a.swap(col, piv);
|
||
q.swap(col, piv);
|
||
for r in 0..m {
|
||
if r == col {
|
||
continue;
|
||
}
|
||
let f = a[r][col] / a[col][col];
|
||
if f == 0.0 {
|
||
continue;
|
||
}
|
||
let prow = a[col].clone();
|
||
for (x, v) in a[r].iter_mut().zip(&prow).skip(col) {
|
||
*x -= f * v;
|
||
}
|
||
let qc = q[col];
|
||
for k in 0..3 {
|
||
q[r][k] -= f * qc[k];
|
||
}
|
||
}
|
||
}
|
||
let ctrl: Vec<mt::Point3> = (0..m).map(|i| mt::Point3::new(q[i][0] / a[i][i], q[i][1] / a[i][i], q[i][2] / a[i][i])).collect();
|
||
mt::BSplineCurve::try_new(mt::KnotVec::from(knots), ctrl).ok()
|
||
}
|
||
|
||
/// A closed wire whose edges are cubic B-splines parameterised (nearly) by arc length, so that
|
||
/// ruled surfaces between sections pair points proportionally along each edge.
|
||
pub(crate) fn uniform_wire(plane: &Plane, lp: &Loop2) -> Result<mt::Wire> {
|
||
const SAMPLES: usize = 24;
|
||
let n = lp.segs.len();
|
||
let verts: Vec<mt::Vertex> = lp.segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect();
|
||
let mut edges = Vec::with_capacity(n);
|
||
for (i, s) in lp.segs.iter().enumerate() {
|
||
let (Some(a), Some(b)) = (verts.get(i), verts.get((i + 1) % n)) else { continue };
|
||
let pts: Vec<Vec3> = (0..SAMPLES).map(|k| plane.to_world(s.point_at(k as f64 / (SAMPLES - 1) as f64))).collect();
|
||
let curve = interpolate_cubic(&pts).ok_or_else(|| KernelError::Failed("loft curve".into()))?;
|
||
edges.push(mt::Edge::new(a, b, mt::Curve::BSplineCurve(curve)));
|
||
}
|
||
Ok(edges.into())
|
||
}
|
||
|
||
/// A sphere as six patches (the cube's faces projected onto it) on revolved surfaces whose poles
|
||
/// and seams lie outside each patch. No face has a singular point, which the boolean needs; the
|
||
/// patches are one analytic sphere, so measured topology counts it as one face.
|
||
pub fn sphere_patches(center: Vec3, radius: f64) -> Result<Body> {
|
||
sphere_patches_rotated(center, radius, [Vec3::X, Vec3::Y, Vec3::Z])
|
||
}
|
||
|
||
/// [`sphere_patches`] with the patch layout turned to the frame `rot` (orthonormal columns).
|
||
pub fn sphere_patches_rotated(center: Vec3, radius: f64, rot: [Vec3; 3]) -> Result<Body> {
|
||
// Every patch is swept from the arc (segment 0) of the half disc a sphere revolves.
|
||
sphere_solid(center, radius, rot, &[0])
|
||
}
|
||
|
||
/// [`sphere_patches_rotated`], every patch swept from profile segments `arcs`.
|
||
fn sphere_solid(center: Vec3, radius: f64, rot: [Vec3; 3], arcs: &[usize]) -> Result<Body> {
|
||
let radius = finite(radius, "radius")?;
|
||
let turn = |v: Vec3| rot[0] * v.x + rot[1] * v.y + rot[2] * v.z;
|
||
if radius <= 1e-6 || !center.is_finite() {
|
||
return Err(KernelError::Invalid("radius must be positive".into()));
|
||
}
|
||
let k = radius / 3f64.sqrt();
|
||
let corner = |sx: f64, sy: f64, sz: f64| center + turn(Vec3::new(sx * k, sy * k, sz * k));
|
||
// Cube faces: outward direction, rotation axis for the patch's surface (perpendicular to it),
|
||
// and the four corner sign triples counter-clockwise seen from outside.
|
||
let faces: [(Vec3, Vec3, [[f64; 3]; 4]); 6] = [
|
||
(Vec3::X, Vec3::Z, [[1.0, -1.0, -1.0], [1.0, 1.0, -1.0], [1.0, 1.0, 1.0], [1.0, -1.0, 1.0]]),
|
||
(-Vec3::X, Vec3::Z, [[-1.0, 1.0, -1.0], [-1.0, -1.0, -1.0], [-1.0, -1.0, 1.0], [-1.0, 1.0, 1.0]]),
|
||
(Vec3::Y, Vec3::Z, [[1.0, 1.0, -1.0], [-1.0, 1.0, -1.0], [-1.0, 1.0, 1.0], [1.0, 1.0, 1.0]]),
|
||
(-Vec3::Y, Vec3::Z, [[-1.0, -1.0, -1.0], [1.0, -1.0, -1.0], [1.0, -1.0, 1.0], [-1.0, -1.0, 1.0]]),
|
||
(Vec3::Z, Vec3::X, [[-1.0, -1.0, 1.0], [1.0, -1.0, 1.0], [1.0, 1.0, 1.0], [-1.0, 1.0, 1.0]]),
|
||
(-Vec3::Z, Vec3::X, [[-1.0, 1.0, -1.0], [1.0, 1.0, -1.0], [1.0, -1.0, -1.0], [-1.0, -1.0, -1.0]]),
|
||
];
|
||
guard("sphere", || {
|
||
let key = |s: [f64; 3]| ((s[0] > 0.0) as usize) | (((s[1] > 0.0) as usize) << 1) | (((s[2] > 0.0) as usize) << 2);
|
||
let mut verts: std::collections::HashMap<usize, mt::Vertex> = std::collections::HashMap::new();
|
||
let mut vert = |s: [f64; 3]| verts.entry(key(s)).or_insert_with(|| builder::vertex(p3(corner(s[0], s[1], s[2])))).clone();
|
||
let mut edges: std::collections::HashMap<(usize, usize), mt::Edge> = std::collections::HashMap::new();
|
||
let mut out: Vec<mt::Face> = Vec::new();
|
||
for (dir, axis, cs) in faces {
|
||
let (dir, axis) = (turn(dir), turn(axis));
|
||
let mut wire: Vec<mt::Edge> = Vec::new();
|
||
for i in 0..4 {
|
||
let (a, b) = (cs[i], cs[(i + 1) % 4]);
|
||
let (ka, kb) = (key(a), key(b));
|
||
let e = match edges.get(&(ka.min(kb), ka.max(kb))) {
|
||
Some(e) => e.clone(),
|
||
None => {
|
||
let (lo, hi) = if ka < kb { (a, b) } else { (b, a) };
|
||
let (va, vb) = (vert(lo), vert(hi));
|
||
let mid = (corner(lo[0], lo[1], lo[2]) + corner(hi[0], hi[1], hi[2])) * 0.5 - center;
|
||
let transit = center + mid.normalized().unwrap_or(dir) * radius;
|
||
let e = builder::circle_arc(&va, &vb, p3(transit));
|
||
edges.insert((ka.min(kb), ka.max(kb)), e.clone());
|
||
e
|
||
}
|
||
};
|
||
wire.push(if ka < kb { e } else { e.inverse() });
|
||
}
|
||
// The meridian runs pole to pole through the side away from the patch.
|
||
let (np, sp, back) = (center + axis * radius, center - axis * radius, center - dir * radius);
|
||
let meridian = builder::circle_arc(&builder::vertex(p3(np)), &builder::vertex(p3(sp)), p3(back));
|
||
let mut surface =
|
||
mt::Surface::RevolutedCurve(mt::Processor::new(mt::RevolutedCurve::by_revolution(meridian.oriented_curve(), p3(center), v3(axis))));
|
||
// Outward normal at the patch centre.
|
||
let probe = p3(center + dir * radius);
|
||
use mt::{ParametricSurface3D, SearchParameter};
|
||
if let Some((u, v)) = surface.search_parameter(probe, mt::SPHint2D::None, 100) {
|
||
let n = surface.normal(u, v);
|
||
if mt::InnerSpace::dot(n, v3(dir)) < 0.0 {
|
||
mt::Invertible::invert(&mut surface);
|
||
}
|
||
}
|
||
out.push(mt::Face::new(vec![wire.into()], surface));
|
||
}
|
||
let shell: mt::Shell = out.into();
|
||
let solid = Solid::try_new(vec![shell]).map_err(|e| KernelError::Failed(format!("sphere: {e}")))?;
|
||
record::attach(Body::new(solid)?, &[], Some(vec![record::profile(arcs); 6]), Default::default())
|
||
})
|
||
}
|