use super::*;
pub fn sweep_profile_along_path(
profile: &[NurbsCurve],
path: &NurbsCurve,
name: Option<&str>,
) -> Result<BrepSolid, String> {
sweep_profile_along_path_stations(
profile,
path,
name,
path_turning_stations(path),
0.0,
None,
SectionPlacement::Transplant,
)
}
pub fn sweep_profile_twisted(
profile: &[NurbsCurve],
path: &NurbsCurve,
twist_angle: f64,
name: Option<&str>,
) -> Result<BrepSolid, String> {
use std::f64::consts::{FRAC_PI_2, TAU};
if !twist_angle.is_finite() {
return Err("sweep_profile_twisted: twist angle must be finite".into());
}
const MAX_TURNS: f64 = 16.0;
if twist_angle.abs() > MAX_TURNS * TAU {
return Err(format!(
"sweep_profile_twisted: twist of {:.3} turns exceeds the {MAX_TURNS}-turn \
limit the 1024-station cap can resolve at 16 stations per quarter turn; \
split the sweep or reduce the twist",
twist_angle.abs() / TAU
));
}
let quarter_turns = (twist_angle.abs() / FRAC_PI_2).ceil() as usize;
let stations = (quarter_turns * 16).clamp(32, 1024).max(path_turning_stations(path));
sweep_profile_along_path_stations(
profile,
path,
name,
stations,
twist_angle,
None,
SectionPlacement::Transplant,
)
.map_err(|error| format!("sweep_profile_twisted: {error}"))
}
fn path_turning_stations(path: &NurbsCurve) -> usize {
use std::f64::consts::FRAC_PI_2;
let turning = path
.domain()
.and_then(|domain| segment_turning(path, domain))
.unwrap_or(0.0);
let quarter_turns = (turning / FRAC_PI_2 - 1e-3).ceil().max(0.0) as usize;
(quarter_turns * 8).clamp(32, 1024)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SectionPlacement {
Transplant,
Rigid,
}
#[derive(Debug, Clone, Copy)]
pub struct ProfileAnchor {
pub origin: Vec3,
pub normal: Vec3,
pub pu: Vec3,
pub pv: Vec3,
}
pub fn sweep_profile_along_path_anchored(
profile: &[NurbsCurve],
path: &NurbsCurve,
name: Option<&str>,
anchor: ProfileAnchor,
) -> Result<BrepSolid, String> {
sweep_profile_along_path_stations(
profile,
path,
name,
path_turning_stations(path),
0.0,
Some(anchor),
SectionPlacement::Transplant,
)
}
pub fn sweep_profile_twisted_anchored(
profile: &[NurbsCurve],
path: &NurbsCurve,
twist_angle: f64,
name: Option<&str>,
anchor: ProfileAnchor,
) -> Result<BrepSolid, String> {
use std::f64::consts::{FRAC_PI_2, TAU};
if !twist_angle.is_finite() {
return Err("sweep_profile_twisted: twist angle must be finite".into());
}
const MAX_TURNS: f64 = 16.0;
if twist_angle.abs() > MAX_TURNS * TAU {
return Err(format!(
"sweep_profile_twisted: twist of {:.3} turns exceeds the {MAX_TURNS}-turn \
limit the 1024-station cap can resolve at 16 stations per quarter turn; \
split the sweep or reduce the twist",
twist_angle.abs() / TAU
));
}
let quarter_turns = (twist_angle.abs() / FRAC_PI_2).ceil() as usize;
let stations = (quarter_turns * 16).clamp(32, 1024).max(path_turning_stations(path));
sweep_profile_along_path_stations(
profile,
path,
name,
stations,
twist_angle,
Some(anchor),
SectionPlacement::Transplant,
)
.map_err(|error| format!("sweep_profile_twisted: {error}"))
}
pub fn sweep_profile_along_chain(
profile: &[NurbsCurve],
path: &SweepPath,
twist_angle: f64,
name: Option<&str>,
anchor: Option<ProfileAnchor>,
placement_mode: SectionPlacement,
corner_advice: &str,
) -> Result<BrepSolid, String> {
use std::f64::consts::{FRAC_PI_2, TAU};
if path.is_empty() {
return Err("sweepSolid: path chain is empty".into());
}
let envelope_lane = super::envelope::recognize_swept_envelope(profile, path, placement_mode)?
.is_ok();
if let ([single], SectionPlacement::Transplant, false, false) =
(path.curves.as_slice(), placement_mode, path.closed, envelope_lane)
{
return match (anchor, twist_angle == 0.0) {
(None, true) => sweep_profile_along_path(profile, single, name),
(Some(anchor), true) => {
sweep_profile_along_path_anchored(profile, single, name, anchor)
}
(None, false) => sweep_profile_twisted(profile, single, twist_angle, name),
(Some(anchor), false) => {
sweep_profile_twisted_anchored(profile, single, twist_angle, name, anchor)
}
};
}
if !twist_angle.is_finite() {
return Err("sweep_profile_along_chain: twist angle must be finite".into());
}
const MAX_TURNS: f64 = 16.0;
if twist_angle.abs() > MAX_TURNS * TAU {
return Err(format!(
"sweep_profile_along_chain: twist of {:.3} turns exceeds the {MAX_TURNS}-turn \
limit the 1024-station cap can resolve at 16 stations per quarter turn; \
split the sweep or reduce the twist",
twist_angle.abs() / TAU
));
}
let stations = if twist_angle == 0.0 {
32
} else {
let counter = if path.closed && path.planar.is_none() {
std::f64::consts::PI
} else {
0.0
};
let quarter_turns = ((twist_angle.abs() + counter) / FRAC_PI_2).ceil() as usize;
(quarter_turns * 16).clamp(32, 1024)
};
let _ = name;
sweep_profile_along_chain_stations(
profile,
path,
stations,
twist_angle,
anchor,
placement_mode,
corner_advice,
CornerPolicy::Mitre,
)
}
pub fn sweep_profile_along_chain_with_stations(
profile: &[NurbsCurve],
path: &SweepPath,
stations: usize,
corner_advice: &str,
) -> Result<BrepSolid, String> {
sweep_profile_along_chain_stations(
profile,
path,
stations.clamp(2, 1024),
0.0,
None,
SectionPlacement::Transplant,
corner_advice,
CornerPolicy::Refuse,
)
}
pub fn profile_anchor(profile: &[NurbsCurve]) -> Result<ProfileAnchor, String> {
let tolerance = 1e-6;
if profile.len() < 2 {
return Err("sweepSolid: profile needs at least 2 curves forming a closed loop".into());
}
let mut samples = Vec::new();
for (index, curve) in profile.iter().enumerate() {
let [start, end] = curve.domain()?;
let next = &profile[(index + 1) % profile.len()];
let next_start = next.domain()?[0];
if curve
.evaluate(end)?
.sub(next.evaluate(next_start)?)
.length()
> tolerance
{
return Err(format!(
"sweepSolid: profile is not closed at curve {index}"
));
}
for sample in 0..16 {
samples.push(curve.evaluate(start + (end - start) * sample as f64 / 16.0)?);
}
}
let normal = crate::polygon::newell_normal(&samples);
let centroid = samples.iter().fold(Vec3::default(), |sum, &point| sum.add(point));
let np = normal
.normalized()
.map_err(|_| "sweepSolid: profile is degenerate (zero enclosed area)".to_string())?;
let origin = centroid.scale(1.0 / samples.len() as f64);
if samples
.iter()
.any(|point| point.sub(origin).dot(np).abs() > tolerance * 100.0)
{
return Err("sweepSolid: profile is not planar".into());
}
let pu = np.perpendicular()?;
let pv = np.cross(pu).normalized()?;
Ok(ProfileAnchor {
origin,
normal: np,
pu,
pv,
})
}
fn sweep_profile_along_path_stations(
profile: &[NurbsCurve],
path: &NurbsCurve,
name: Option<&str>,
stations: usize,
twist_angle: f64,
anchor: Option<ProfileAnchor>,
placement_mode: SectionPlacement,
) -> Result<BrepSolid, String> {
let _ = name;
let tolerance = 1e-6;
if stations < 2 {
return Err("sweepSolid: need at least 2 stations".into());
}
let placement = resolve_placement(profile, anchor, placement_mode)?;
let [t0, t1] = path.domain()?;
if (t1 - t0).abs() <= tolerance {
return Err("sweepSolid: path domain is degenerate".into());
}
let mut points = Vec::with_capacity(stations);
let mut tangents = Vec::with_capacity(stations);
let mut curvatures = Vec::with_capacity(stations);
for index in 0..stations {
let t = t0 + (t1 - t0) * index as f64 / (stations - 1) as f64;
let derivatives = path.derivatives_small(t, 2)?;
let tangent = derivatives[1]
.normalized()
.map_err(|_| format!("sweepSolid: path tangent is degenerate at station {index}"))?;
points.push(derivatives[0]);
tangents.push(tangent);
curvatures.push(curvature_vector(derivatives[1], derivatives[2]));
}
sweep_sections_through_samples(
profile,
&points,
&tangents,
&curvatures,
twist_angle,
placement,
placement_mode,
RingFrame::Open,
None,
None,
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CornerPolicy {
Mitre,
Refuse,
}
fn place_section(
profile: &[NurbsCurve],
placement: ProfileAnchor,
placement_mode: SectionPlacement,
start: Vec3,
tangent: Vec3,
) -> Result<(Vec<NurbsCurve>, Vec3), String> {
if placement_mode == SectionPlacement::Rigid {
return Ok((profile.to_vec(), placement.normal));
}
let ProfileAnchor { origin, pu, pv, .. } = placement;
let r0 = tangent.perpendicular()?;
let s0 = tangent.cross(r0).normalized()?;
let mut placed = Vec::with_capacity(profile.len());
for curve in profile {
let control_points = curve
.control_points
.iter()
.map(|control| {
let point = control.point()?;
let local = point.sub(origin);
let world = start
.add(r0.scale(local.dot(pu)))
.add(s0.scale(local.dot(pv)));
Ok(Vec4::from_point(world, control.w))
})
.collect::<Result<Vec<_>, String>>()?;
placed.push(NurbsCurve::new(
curve.degree,
curve.knots.clone(),
control_points,
)?);
}
Ok((placed, tangent))
}
fn resolve_placement(
profile: &[NurbsCurve],
anchor: Option<ProfileAnchor>,
placement_mode: SectionPlacement,
) -> Result<ProfileAnchor, String> {
let computed = profile_anchor(profile)?;
Ok(match placement_mode {
SectionPlacement::Rigid => computed,
SectionPlacement::Transplant => anchor.unwrap_or(computed),
})
}
#[allow(clippy::too_many_arguments)]
fn sweep_profile_along_chain_stations(
profile: &[NurbsCurve],
path: &SweepPath,
stations: usize,
twist_angle: f64,
anchor: Option<ProfileAnchor>,
placement_mode: SectionPlacement,
corner_advice: &str,
corners: CornerPolicy,
) -> Result<BrepSolid, String> {
let chain = path.curves.as_slice();
if chain.is_empty() {
return Err("sweepSolid: path chain is empty".into());
}
if stations < 2 {
return Err("sweepSolid: need at least 2 stations".into());
}
let placement = resolve_placement(profile, anchor, placement_mode)?;
let envelope_gap =
match super::envelope::recognize_swept_envelope(profile, path, placement_mode)? {
Ok(plan) => {
let mut bodies = super::envelope::build_swept_envelope(&plan)?;
if bodies.len() == 1 {
return Ok(bodies.remove(0));
}
return Err(format!(
"{}: a section that crosses the path's axis (ρ·κ = {:.6}) sweeps TWO \
bodies — the near lobe over the path's own azimuths and the far lobe half \
a turn from them, meeting only along the axis — and this entry returns one \
solid. {} were built; ask `sweep_envelope_bodies` for the pair",
SWEEP_ENVELOPE_REFUSAL,
plan.ratio(),
bodies.len()
));
}
Err(reason) => reason,
};
if let Some(corner) = path.corner() {
if corners == CornerPolicy::Mitre && path.cornered_polyline() {
if twist_angle != 0.0 && !path.closed {
return Err(
"sweepSolid: a CORNERED path cannot carry a twist — its corners are mitred \
from exact pieces that meet on the joint's bisector plane, and a twist laid \
down along the path would roll the two sides of that plane apart, so they \
would no longer share one loop there. Only a CLOSED frame rolls a twist along \
the middle of its sides. Sweep the run without the twist; a \
tangent-continuous (smooth) path is skinned through stations and carries one"
.into(),
);
}
let (section, section_normal, _) =
mitre_section(profile, placement, placement_mode, path)?;
return super::miter::sweep_section_mitered(
§ion,
section_normal,
path,
twist_angle,
);
}
let curved = (0..path.len())
.find(|index| {
let curve = &path.curves[*index];
curve.degree != 1 || curve.control_points.len() != 2
})
.map(|index| format!(" — segment '{}' is CURVED", path.name(index)))
.unwrap_or_default();
let mixed = if curved.is_empty() {
String::new()
} else {
" A path that MIXES the two — corners at some joints and curved segments elsewhere — is in neither lane: every segment straight is mitred (open or closed), every joint tangent-continuous is skinned, and nothing builds one construction for some joints and the other for the rest."
.to_string()
};
return Err(format!(
"the path must be tangent-continuous: segments '{}' and '{}' meet at a {:.1}° \
corner{curved}. The profile is skinned between sampled stations along the path, so a \
corner would be ROUNDED OFF rather than mitred, and a mitre needs two STRAIGHT \
segments: it trims both sweeps to the plane that bisects their directions, and a \
curve has no single direction to bisect.{mixed} {corner_advice}",
path.name(corner.before),
path.name(corner.after),
corner.tangent_break.to_degrees()
));
}
let StationSamples {
points,
tangents,
curvatures,
segments,
} = chain_stations(path, stations)?;
let closure = ring_frame(path, &tangents)?;
sweep_sections_through_samples(
profile,
&points,
&tangents,
&curvatures,
twist_angle,
placement,
placement_mode,
closure,
Some(envelope_gap.as_str()),
Some((segments.as_slice(), path)),
)
}
#[derive(Debug, Clone, Copy)]
enum RingFrame {
Open,
Planar(Vec3),
Spatial,
}
impl RingFrame {
fn is_ring(self) -> bool {
!matches!(self, RingFrame::Open)
}
}
fn ring_frame(path: &SweepPath, tangents: &[Vec3]) -> Result<RingFrame, String> {
Ok(match (path.closed, path.planar) {
(false, _) => RingFrame::Open,
(true, Some(normal)) => RingFrame::Planar(ring_normal(normal, tangents)?),
(true, None) => RingFrame::Spatial,
})
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SweepClosure {
pub holonomy: f64,
pub requested_twist: f64,
pub applied_twist: f64,
pub curve_shift: usize,
}
pub fn sweep_closure(
profile: &[NurbsCurve],
path: &SweepPath,
placement_mode: SectionPlacement,
stations: usize,
twist_angle: f64,
) -> Result<Option<SweepClosure>, String> {
if !path.closed {
return Ok(None);
}
let placement = resolve_placement(profile, None, placement_mode)?;
if path.cornered_polyline() {
let (section, section_normal, _) = mitre_section(profile, placement, placement_mode, path)?;
return super::miter::mitred_frame_closure(§ion, section_normal, path, twist_angle)
.map(Some);
}
let StationSamples {
points,
tangents,
segments,
..
} = chain_stations(path, stations.clamp(2, 1024))?;
let screw = Some((segments.as_slice(), path)).filter(|_| placement_mode == SectionPlacement::Rigid);
let closure = ring_frame(path, &tangents)?;
let (section, _) = place_section(profile, placement, placement_mode, points[0], tangents[0])?;
let requested = ring_closing_twist(§ion, &points, &tangents, twist_angle)?;
Ok(station_frames(&points, &tangents, closure, screw, requested)?.2)
}
fn ring_closing_twist(
section: &[NurbsCurve],
points: &[Vec3],
tangents: &[Vec3],
twist_angle: f64,
) -> Result<ClosingTwist, String> {
let lap: f64 = (0..points.len())
.map(|index| points[(index + 1) % points.len()].sub(points[index]).length())
.sum();
closing_twist(section, points[0], tangents[0], twist_angle, 1e-6 * lap)
}
fn mitre_section(
profile: &[NurbsCurve],
placement: ProfileAnchor,
placement_mode: SectionPlacement,
path: &SweepPath,
) -> Result<(Vec<NurbsCurve>, Vec3, Vec3), String> {
let [t0, t1] = path.curves[0].domain()?;
let start = path.curves[0].evaluate(t0)?;
let tangent = path.curves[0]
.evaluate(t1)?
.sub(start)
.normalized()
.map_err(|_| "sweepSolid: path segment 0 has zero length".to_string())?;
let (section, section_normal) =
place_section(profile, placement, placement_mode, start, tangent)?;
Ok((section, section_normal, tangent))
}
fn station_frames(
points: &[Vec3],
tangents: &[Vec3],
closure: RingFrame,
screw: Option<(&[usize], &SweepPath)>,
requested: ClosingTwist,
) -> Result<(Vec<Vec3>, Vec<Vec3>, Option<SweepClosure>), String> {
match closure {
RingFrame::Open => {
let (r_axes, s_axes) = transported_frames(points, tangents, screw)?;
Ok((r_axes, s_axes, None))
}
RingFrame::Planar(normal) => {
let mut r_axes = Vec::with_capacity(points.len());
let mut s_axes = Vec::with_capacity(points.len());
for tangent in tangents {
let r = normal
.sub(tangent.scale(normal.dot(*tangent)))
.normalized()
.map_err(|_| {
"sweepSolid: the closed path's plane normal is parallel to its tangent"
.to_string()
})?;
s_axes.push(tangent.cross(r).normalized()?);
r_axes.push(r);
}
if requested.twist != 0.0 {
roll_over_the_lap(points, &mut r_axes, &mut s_axes, requested.twist)?;
}
Ok((
r_axes,
s_axes,
Some(SweepClosure {
holonomy: 0.0,
requested_twist: requested.twist,
applied_twist: requested.twist,
curve_shift: requested.shift,
}),
))
}
RingFrame::Spatial => {
let (r_axes, s_axes, closure) =
ring_closure_frames(points, tangents, screw, requested)?;
Ok((r_axes, s_axes, Some(closure)))
}
}
}
fn ring_closure_frames(
points: &[Vec3],
tangents: &[Vec3],
screw: Option<(&[usize], &SweepPath)>,
requested: ClosingTwist,
) -> Result<(Vec<Vec3>, Vec<Vec3>, SweepClosure), String> {
let stations = points.len();
if stations < 2 {
return Err("sweepSolid: need at least 2 stations".into());
}
let mut lap_points = points.to_vec();
lap_points.push(points[0]);
let mut lap_tangents = tangents.to_vec();
lap_tangents.push(tangents[0]);
let lap_segments: Option<Vec<usize>> = screw.map(|(segments, _)| {
let mut extended = segments.to_vec();
extended.push(segments[stations - 1]);
extended
});
let lap_screw = match (&lap_segments, screw) {
(Some(segments), Some((_, path))) => Some((segments.as_slice(), path)),
_ => None,
};
let (mut r_axes, mut s_axes) = transported_frames(&lap_points, &lap_tangents, lap_screw)?;
let returned = r_axes.pop().expect("a lap has a wrap station");
s_axes.pop();
let holonomy = returned.dot(s_axes[0]).atan2(returned.dot(r_axes[0]));
let applied = if requested.twist == 0.0 {
-holonomy
} else {
requested.twist - holonomy
};
roll_over_the_lap(points, &mut r_axes, &mut s_axes, applied)?;
Ok((
r_axes,
s_axes,
SweepClosure {
holonomy,
requested_twist: requested.twist,
applied_twist: applied,
curve_shift: requested.shift,
},
))
}
fn roll_over_the_lap(
points: &[Vec3],
r_axes: &mut [Vec3],
s_axes: &mut [Vec3],
roll: f64,
) -> Result<(), String> {
let stations = points.len();
let mut travelled = Vec::with_capacity(stations);
let mut length = 0.0;
for index in 0..stations {
travelled.push(length);
length += points[(index + 1) % stations].sub(points[index]).length();
}
if length <= 0.0 {
return Err("sweepSolid: the closed path has zero length".into());
}
for station in 0..stations {
let phi = roll * travelled[station] / length;
let (sin_phi, cos_phi) = phi.sin_cos();
let (r, s) = (r_axes[station], s_axes[station]);
r_axes[station] = r.scale(cos_phi).add(s.scale(sin_phi));
s_axes[station] = s.scale(cos_phi).sub(r.scale(sin_phi));
}
Ok(())
}
fn transported_frames(
points: &[Vec3],
tangents: &[Vec3],
screw: Option<(&[usize], &SweepPath)>,
) -> Result<(Vec<Vec3>, Vec<Vec3>), String> {
let stations = points.len();
let mut r_axes = Vec::with_capacity(stations);
let mut s_axes = Vec::with_capacity(stations);
{
let r0 = tangents[0].perpendicular()?; s_axes.push(tangents[0].cross(r0).normalized()?);
r_axes.push(r0);
let mut roll: Option<(usize, f64)> = None;
for index in 0..stations - 1 {
let t_next = tangents[index + 1];
let helix = screw.and_then(|(segments, path)| {
let segment = segments[index + 1];
path.screw_axes[segment].map(|axis| (segment, axis, path))
});
let r_next = match helix {
None => rmf_step(
r_axes[index],
tangents[index],
t_next,
points[index + 1].sub(points[index]),
)
.ok_or_else(|| format!("sweepSolid: frame degenerated at station {index}"))?,
Some((segment, axis, path)) => {
let phi = match roll {
Some((fixed, phi)) if fixed == segment => phi,
_ => {
let tangent = tangents[index];
let reference =
screw_reference(axis, tangent, path.name(segment), index)?;
let r = r_axes[index];
let phi = tangent.dot(reference.cross(r)).atan2(reference.dot(r));
roll = Some((segment, phi));
phi
}
};
let reference = screw_reference(axis, t_next, path.name(segment), index + 1)?;
let (sin_phi, cos_phi) = phi.sin_cos();
reference
.scale(cos_phi)
.add(t_next.cross(reference).scale(sin_phi))
}
};
s_axes.push(t_next.cross(r_next).normalized()?);
r_axes.push(r_next);
}
}
Ok((r_axes, s_axes))
}
fn screw_reference(axis: Vec3, tangent: Vec3, segment: &str, station: usize) -> Result<Vec3, String> {
axis.sub(tangent.scale(axis.dot(tangent)))
.normalized()
.map_err(|_| {
format!(
"sweepSolid: path segment '{segment}' runs along its own helix axis at station \
{station}, so the screw motion that carries the section has no direction \
about the path there"
)
})
}
pub(crate) struct StationSamples {
pub(crate) points: Vec<Vec3>,
pub(crate) tangents: Vec<Vec3>,
pub(crate) curvatures: Vec<Vec3>,
pub(crate) segments: Vec<usize>,
}
pub(crate) fn curvature_vector(first: Vec3, second: Vec3) -> Vec3 {
let speed_squared = first.dot(first);
if speed_squared <= 0.0 {
return Vec3::default();
}
let tangent = first.scale(1.0 / speed_squared.sqrt());
second
.sub(tangent.scale(second.dot(tangent)))
.scale(1.0 / speed_squared)
}
fn chain_stations(path: &SweepPath, stations: usize) -> Result<StationSamples, String> {
let tolerance = 1e-6;
let chain = path.curves.as_slice();
let mut domains = Vec::with_capacity(chain.len());
let mut lengths = Vec::with_capacity(chain.len());
let mut turning = Vec::with_capacity(chain.len());
for (index, curve) in chain.iter().enumerate() {
let [t0, t1] = curve.domain()?;
if (t1 - t0).abs() <= tolerance {
return Err(format!(
"sweepSolid: path segment {index} has a degenerate domain"
));
}
let mut length = 0.0;
let mut previous = curve.evaluate(t0)?;
for sample in 1..=16 {
let point = curve.evaluate(t0 + (t1 - t0) * sample as f64 / 16.0)?;
length += point.sub(previous).length();
previous = point;
}
domains.push([t0, t1]);
lengths.push(length);
turning.push(segment_turning(curve, [t0, t1]).map_err(|error| {
format!("sweepSolid: path tangent is degenerate on segment {index}: {error}")
})?);
}
let total_length: f64 = lengths.iter().sum();
if total_length <= tolerance {
return Err("sweepSolid: path chain has zero length".into());
}
let floors: Vec<usize> = turning
.iter()
.map(|turn| ((turn / std::f64::consts::FRAC_PI_2) * 16.0).ceil() as usize)
.collect();
let floored_length: f64 = lengths
.iter()
.zip(&floors)
.filter(|(_, floor)| **floor > 2)
.map(|(length, _)| length)
.sum();
let floor_total: usize = floors.iter().sum();
let lifted = if !path.closed && floored_length > tolerance {
let straight = floor_total as f64 * (total_length - floored_length) / floored_length;
((floor_total as f64 + straight).ceil() as usize).min(1024)
} else {
0
};
let stations = stations.max(lifted);
let mut budget: Vec<usize> = lengths
.iter()
.zip(&floors)
.map(|(length, floor)| {
let share = (stations as f64 * length / total_length).round() as usize;
share.max(*floor).max(2)
})
.collect();
let shared = chain.len() - 1 + usize::from(path.closed);
let emitted: usize = budget.iter().sum::<usize>() - shared;
const MAX_STATIONS: usize = 1024;
if emitted > MAX_STATIONS {
return Err(format!(
"sweepSolid: a {}-segment path needs at least {emitted} stations, past the \
{MAX_STATIONS}-station cap the loft solve can carry; sweep it in fewer, \
longer pieces",
chain.len()
));
}
if emitted < stations {
let longest = lengths
.iter()
.enumerate()
.max_by(|a, b| a.1.total_cmp(b.1))
.map(|(index, _)| index)
.unwrap_or(0);
budget[longest] += stations - emitted;
}
let mut points: Vec<Vec3> = Vec::new();
let mut tangents: Vec<Vec3> = Vec::new();
let mut curvatures: Vec<Vec3> = Vec::new();
let mut segments: Vec<usize> = Vec::new();
for (index, curve) in chain.iter().enumerate() {
let [t0, t1] = domains[index];
let count = budget[index];
let first = usize::from(index > 0);
if first == 1 {
let derivatives = curve.derivatives_small(t0, 2)?;
let arriving = curvature_vector(derivatives[1], derivatives[2]);
if let Some(departing) = curvatures.last_mut() {
if arriving.length() > departing.length() {
*departing = arriving;
}
}
}
let last = count - usize::from(path.closed && index + 1 == chain.len());
for sample in first..last {
let t = t0 + (t1 - t0) * sample as f64 / (count - 1) as f64;
let derivatives = curve.derivatives_small(t, 2)?;
let tangent = derivatives[1].normalized().map_err(|_| {
format!("sweepSolid: path tangent is degenerate on segment {index}")
})?;
points.push(derivatives[0]);
tangents.push(tangent);
curvatures.push(curvature_vector(derivatives[1], derivatives[2]));
segments.push(index);
}
}
if path.closed {
if let (Some(curve), Some(domain)) = (chain.last(), domains.last()) {
let derivatives = curve.derivatives_small(domain[1], 2)?;
let arriving = curvature_vector(derivatives[1], derivatives[2]);
if let Some(departing) = curvatures.first_mut() {
if arriving.length() > departing.length() {
*departing = arriving;
}
}
}
}
Ok(StationSamples {
points,
tangents,
curvatures,
segments,
})
}
fn ring_normal(normal: Vec3, tangents: &[Vec3]) -> Result<Vec3, String> {
for (index, tangent) in tangents.iter().enumerate() {
if tangent.cross(normal).length() < 0.5 {
return Err(format!(
"sweepSolid: the closed path's own plane normal is within 30° of its tangent \
at station {index}, so the path does not lie in that plane after all"
));
}
}
normal.normalized().map_err(|_| {
"sweepSolid: the closed path's plane normal is degenerate".to_string()
})
}
fn segment_turning(curve: &NurbsCurve, domain: [f64; 2]) -> Result<f64, String> {
let (total, widest) = turning_at(curve, domain, 16)?;
if widest <= TURNING_ALIAS_STEP {
return Ok(total);
}
let spans = curve.knots.windows(2).filter(|pair| pair[1] > pair[0]).count();
Ok(turning_at(curve, domain, (spans * 16).clamp(64, 8192))?.0)
}
const TURNING_ALIAS_STEP: f64 = std::f64::consts::PI / 8.0;
fn turning_at(curve: &NurbsCurve, domain: [f64; 2], steps: usize) -> Result<(f64, f64), String> {
let [t0, t1] = domain;
let mut total = 0.0;
let mut widest: f64 = 0.0;
let mut previous = curve.unit_tangent(t0, t0, t1)?;
for sample in 1..=steps {
let tangent = curve.unit_tangent(t0 + (t1 - t0) * sample as f64 / steps as f64, t0, t1)?;
let step = previous.dot(tangent).clamp(-1.0, 1.0).acos();
total += step;
widest = widest.max(step);
previous = tangent;
}
Ok((total, widest))
}
fn rmf_step(r: Vec3, t: Vec3, t_next: Vec3, step: Vec3) -> Option<Vec3> {
let c1 = step.dot(step);
let candidate = if c1 <= 1e-18 {
r
} else {
let reflected_r = r.sub(step.scale(2.0 / c1 * step.dot(r)));
let reflected_t = t.sub(step.scale(2.0 / c1 * step.dot(t)));
let v2 = t_next.sub(reflected_t);
let c2 = v2.dot(v2);
if c2 <= 1e-18 {
reflected_r
} else {
reflected_r.sub(v2.scale(2.0 / c2 * v2.dot(reflected_r)))
}
};
candidate
.sub(t_next.scale(candidate.dot(t_next)))
.normalized()
.ok()
}
const BEND_SECTION_SAMPLES: usize = 64;
#[derive(Debug, Clone, Copy)]
pub struct BendStation {
pub station: usize,
pub point: Vec3,
pub curvature: f64,
pub radius: f64,
pub reach: f64,
pub ratio: f64,
}
fn section_offsets(
profile: &[NurbsCurve],
placement: &ProfileAnchor,
) -> Result<Vec<(f64, f64)>, String> {
let ProfileAnchor { origin, pu, pv, .. } = *placement;
let mut offsets = Vec::with_capacity(profile.len() * BEND_SECTION_SAMPLES);
for curve in profile {
let [start, end] = curve.domain()?;
for sample in 0..BEND_SECTION_SAMPLES {
let t = start + (end - start) * sample as f64 / BEND_SECTION_SAMPLES as f64;
let local = curve.evaluate(t)?.sub(origin);
offsets.push((local.dot(pu), local.dot(pv)));
}
}
Ok(offsets)
}
fn bend_stations(
points: &[Vec3],
curvatures: &[Vec3],
axes: &[(Vec3, Vec3)],
offsets: &[(f64, f64)],
) -> Vec<BendStation> {
(0..points.len())
.map(|station| {
let curvature = curvatures[station].length();
if curvature <= 0.0 {
return BendStation {
station,
point: points[station],
curvature: 0.0,
radius: f64::INFINITY,
reach: 0.0,
ratio: 0.0,
};
}
let normal = curvatures[station].scale(1.0 / curvature);
let (r_axis, s_axis) = axes[station];
let (nu, nv) = (normal.dot(r_axis), normal.dot(s_axis));
let reach = offsets
.iter()
.map(|(a, b)| a * nu + b * nv)
.fold(f64::NEG_INFINITY, f64::max);
BendStation {
station,
point: points[station],
curvature,
radius: 1.0 / curvature,
reach,
ratio: reach * curvature,
}
})
.collect()
}
pub fn sweep_bend_profile(
profile: &[NurbsCurve],
path: &SweepPath,
stations: usize,
) -> Result<Vec<BendStation>, String> {
let placement = resolve_placement(profile, None, SectionPlacement::Transplant)?;
let StationSamples {
points,
tangents,
curvatures,
..
} = chain_stations(path, stations.clamp(2, 1024))?;
let closure = ring_frame(path, &tangents)?;
let untwisted = ClosingTwist {
twist: 0.0,
shift: 0,
};
let (r_axes, s_axes, _) = station_frames(&points, &tangents, closure, None, untwisted)?;
let axes: Vec<(Vec3, Vec3)> = r_axes.into_iter().zip(s_axes).collect();
let offsets = section_offsets(profile, &placement)?;
Ok(bend_stations(&points, &curvatures, &axes, &offsets))
}
pub const SWEEP_TIGHT_BEND_REFUSAL: &str = "sweepSolid: the path bends TIGHTER than the section";
fn tight_bend_refusal(profile: &[BendStation], stations: usize) -> Option<String> {
let folded = profile.iter().filter(|station| station.ratio >= 1.0).count();
if folded == 0 {
return None;
}
let worst = profile
.iter()
.max_by(|a, b| a.ratio.total_cmp(&b.ratio))
.expect("a folded station exists");
Some(format!(
"{SWEEP_TIGHT_BEND_REFUSAL} at station {} of {stations} ({:.4}, {:.4}, {:.4}): the path's \
radius of curvature there is {:.6} and the section reaches {:.6} into the bend, so \
ρ·κ = {:.3} ≥ 1 ({folded} of {stations} stations do). A section carried through a bend \
tighter than its own reach sweeps an envelope that passes through ITSELF — the solid \
there is that envelope trimmed at its own self-intersection, which this lane does not \
build: it skins the section between stations, and past ρ·κ = 1 the sections CROSS and \
the wall folds. Ease the bend to a radius above {:.6}, or narrow the section until it \
reaches no further than {:.6} (a round section of diameter {:.6})",
worst.station,
worst.point.x,
worst.point.y,
worst.point.z,
worst.radius,
worst.reach,
worst.ratio,
worst.reach,
worst.radius,
2.0 * worst.radius,
))
}
#[allow(clippy::too_many_arguments)]
fn sweep_sections_through_samples(
profile: &[NurbsCurve],
points: &[Vec3],
tangents: &[Vec3],
curvatures: &[Vec3],
twist_angle: f64,
placement: ProfileAnchor,
placement_mode: SectionPlacement,
closure: RingFrame,
envelope_gap: Option<&str>,
chain: Option<(&[usize], &SweepPath)>,
) -> Result<BrepSolid, String> {
let tolerance = 1e-6;
let stations = points.len();
if stations < 2 {
return Err("sweepSolid: need at least 2 stations".into());
}
let ProfileAnchor { origin, pu, pv, .. } = placement;
let twist_fractions: Option<Vec<f64>> = if twist_angle != 0.0 && !closure.is_ring() {
let mut cumulative = vec![0.0; stations];
let mut total = 0.0;
for index in 1..stations {
total += points[index].sub(points[index - 1]).length();
cumulative[index] = total;
}
if total <= tolerance {
return Err("sweepSolid: path has zero length; cannot distribute the twist".into());
}
for length in &mut cumulative {
*length /= total;
}
Some(cumulative)
} else {
None
};
let screw = chain.filter(|_| placement_mode == SectionPlacement::Rigid);
let requested = if closure.is_ring() && twist_angle != 0.0 {
let (section, _) = place_section(profile, placement, placement_mode, points[0], tangents[0])?;
ring_closing_twist(§ion, points, tangents, twist_angle)?
} else {
ClosingTwist {
twist: 0.0,
shift: 0,
}
};
let (r_axes, s_axes, _) = station_frames(points, tangents, closure, screw, requested)?;
if !closure.is_ring() {
let travel: f64 = points
.windows(2)
.map(|pair| pair[1].sub(pair[0]).length())
.sum();
let gap = points[stations - 1].sub(points[0]).length();
if travel > tolerance && gap <= 1e-3 * travel {
return Err(format!(
"sweepSolid: the path returns to where it started ({gap:.3e} apart after \
travelling {travel:.3e}), so the sweep's two end caps would land on top of each \
other. A closed path swept this way IS a revolution — build it with Revolve \
about the same axis, or close the path exactly so it sweeps as a capless ring, \
or sweep the run in two halves and union them"
));
}
}
let path_start = points[0];
let (t0, r0, s0) = (tangents[0], r_axes[0], s_axes[0]);
let rigid = placement_mode == SectionPlacement::Rigid;
let mut sections: Vec<Vec<NurbsCurve>> = Vec::with_capacity(stations);
let mut station_points: Vec<Vec<Vec3>> = Vec::with_capacity(if rigid { stations } else { 0 });
let mut section_normals: Vec<Vec3> = Vec::with_capacity(if rigid { stations } else { 0 });
let mut anchor_track: Vec<Vec3> = Vec::with_capacity(if rigid { stations } else { 0 });
let mut placed_axes: Vec<(Vec3, Vec3)> = Vec::with_capacity(stations);
for station in 0..stations {
let station_origin = points[station];
let (ri, si) = match &twist_fractions {
Some(fractions) => {
let phi = twist_angle * fractions[station];
let (sin_phi, cos_phi) = phi.sin_cos();
let r = r_axes[station];
let s = s_axes[station];
(
r.scale(cos_phi).add(s.scale(sin_phi)),
s.scale(cos_phi).sub(r.scale(sin_phi)),
)
}
None => (r_axes[station], s_axes[station]),
};
placed_axes.push((ri, si));
let tk = tangents[station];
let place = |x: Vec3| -> Vec3 {
if rigid {
let local = x.sub(path_start);
station_origin
.add(tk.scale(local.dot(t0)))
.add(ri.scale(local.dot(r0)))
.add(si.scale(local.dot(s0)))
} else {
let local = x.sub(origin);
station_origin
.add(ri.scale(local.dot(pu)))
.add(si.scale(local.dot(pv)))
}
};
if rigid {
let n0 = placement.normal;
section_normals.push(
tk.scale(n0.dot(t0))
.add(ri.scale(n0.dot(r0)))
.add(si.scale(n0.dot(s0))),
);
anchor_track.push(place(origin));
station_points.push(Vec::with_capacity(
profile.iter().map(|c| c.control_points.len()).sum(),
));
}
let mut section = Vec::with_capacity(profile.len());
for curve in profile {
let control_points = curve
.control_points
.iter()
.map(|point| {
let weight = point.w;
let euclidean = Vec3::new(point.x / weight, point.y / weight, point.z / weight);
let world = place(euclidean);
if rigid {
station_points[station].push(world);
}
Vec4 {
x: world.x * weight,
y: world.y * weight,
z: world.z * weight,
w: weight,
}
})
.collect();
section.push(NurbsCurve::new(
curve.degree,
curve.knots.clone(),
control_points,
)?);
}
sections.push(section);
}
let step_count = if closure.is_ring() {
stations
} else {
stations - 1
};
let next_station = |station: usize| (station + 1) % stations;
if rigid {
let landing_offset: usize = profile[..requested.shift]
.iter()
.map(|curve| curve.control_points.len())
.sum();
let mut most_positive = 0.0f64;
let mut most_negative = 0.0f64;
let mut longest_step = 0.0f64;
for station in 0..step_count {
let normal = section_normals[station];
let arriving = &station_points[next_station(station)];
let turn = if next_station(station) == 0 { landing_offset } else { 0 };
for (before, after) in station_points[station]
.iter()
.zip(arriving[turn..].iter().chain(&arriving[..turn]))
{
let step = after.sub(*before);
longest_step = longest_step.max(step.length());
let advance = step.dot(normal);
most_positive = most_positive.max(advance);
most_negative = most_negative.min(advance);
}
}
let noise = 1e-9 * longest_step.max(tolerance);
if most_positive > noise && most_negative < -noise {
return Err(
"sweepSolid: the profile sweeps back through itself — part of it advances along \
the path while part of it retreats. A turning path carries the profile around \
the turn's own axis, so a profile that STRADDLES that axis folds into itself; \
move the profile clear of the axis, or sweep the run in pieces"
.into(),
);
}
if most_positive <= noise && most_negative >= -noise {
return Err(
"sweepSolid: the path does not advance through the profile — it runs inside the \
profile plane, so the sweep encloses no volume"
.into(),
);
}
for station in 0..step_count {
let step = anchor_track[next_station(station)].sub(anchor_track[station]);
let length = step.length();
if length <= noise {
continue;
}
if step.dot(section_normals[station]).abs() / length < 0.1 {
return Err(
"sweepSolid: the path is nearly parallel to the profile plane, which sweeps a \
sliver rather than a solid"
.into(),
);
}
}
}
if !rigid && std::env::var("BREP_SWEEP_BEND_GUARD").as_deref() != Ok("0") {
let offsets = section_offsets(profile, &placement)?;
let profile_stations = bend_stations(points, curvatures, &placed_axes, &offsets);
if let Some(refusal) = tight_bend_refusal(&profile_stations, stations) {
return Err(match envelope_gap {
Some(gap) if !gap.is_empty() => format!(
"{refusal}. The swept envelope trimmed at its own self-intersection is built \
for a CIRCLE carried along one circular planar arc, and this sweep is not \
that: {gap}"
),
_ => refusal,
});
}
}
match closure {
RingFrame::Planar(_) | RingFrame::Spatial => {
crate::loft_profile_brep_closed_shifted(§ions, requested.shift).map_err(|error| {
format!("sweepSolid: closed loft through swept sections failed: {error}")
})
}
RingFrame::Open => loft_profile_brep(§ions)
.map_err(|error| format!("sweepSolid: loft through swept sections failed: {error}")),
}
}
#[allow(clippy::too_many_arguments)]
pub fn helix_sample_points(
axis_origin: Vec3,
axis_direction: Vec3,
reference: Option<Vec3>,
start_radius: f64,
end_radius: f64,
pitch: f64,
turns: f64,
start_angle: f64,
left_handed: bool,
) -> Result<(Vec<Vec3>, Vec<f64>), String> {
use std::f64::consts::TAU;
let w = axis_direction
.normalized()
.map_err(|_| "helix: axis direction is degenerate".to_string())?;
for (name, value) in [
("radius", start_radius),
("end radius", end_radius),
("pitch", pitch),
("turns", turns),
("start angle", start_angle),
] {
if !value.is_finite() {
return Err(format!("helix: {name} must be a finite number"));
}
}
if start_radius < 0.0 || end_radius < 0.0 {
return Err("helix: radius must not be negative".into());
}
if pitch < 0.0 {
return Err("helix: pitch must not be negative".into());
}
if turns <= 0.0 {
return Err("helix: turns must be positive".into());
}
if turns > 256.0 {
return Err("helix: turns must be at most 256".into());
}
if start_radius == 0.0 && end_radius == 0.0 && pitch == 0.0 {
return Err("helix: zero radius and zero pitch describe a single point".into());
}
let u = match reference {
Some(reference) => {
let radial = reference.sub(w.scale(reference.dot(w)));
radial.normalized().or_else(|_| w.perpendicular())?
}
None => w.perpendicular()?,
};
let v = w.cross(u).normalized()?;
let total_angle = turns * TAU;
let height = pitch * turns;
let sign = if left_handed { -1.0 } else { 1.0 };
let count = ((turns * 64.0).ceil() as usize + 1).clamp(9, 4097);
let mut points = Vec::with_capacity(count);
let mut parameters = Vec::with_capacity(count);
for index in 0..count {
let s = index as f64 / (count - 1) as f64;
let theta = start_angle + sign * total_angle * s;
let radius = start_radius + (end_radius - start_radius) * s;
points.push(
axis_origin
.add(u.scale(radius * theta.cos()))
.add(v.scale(radius * theta.sin()))
.add(w.scale(height * s)),
);
parameters.push(s);
}
Ok((points, parameters))
}
#[allow(clippy::too_many_arguments)]
pub fn fit_helix_curve(
axis_origin: Vec3,
axis_direction: Vec3,
reference: Option<Vec3>,
start_radius: f64,
end_radius: f64,
pitch: f64,
turns: f64,
start_angle: f64,
left_handed: bool,
) -> Result<NurbsCurve, String> {
let (points, parameters) = helix_sample_points(
axis_origin,
axis_direction,
reference,
start_radius,
end_radius,
pitch,
turns,
start_angle,
left_handed,
)?;
interpolate_curve(&points, 3, ¶meters)
}
pub fn sweep_profile_helix(
profile: &[NurbsCurve],
axis_origin: Vec3,
axis_direction: Vec3,
helix_radius: f64,
pitch: f64,
turns: f64,
name: Option<&str>,
) -> Result<BrepSolid, String> {
use std::f64::consts::TAU;
let w = axis_direction
.normalized()
.map_err(|_| "sweep_profile_helix: axis direction is degenerate".to_string())?;
if !(helix_radius.is_finite() && helix_radius > 0.0) {
return Err("sweep_profile_helix: helix radius must be positive".into());
}
if !(pitch.is_finite() && pitch > 0.0) {
return Err("sweep_profile_helix: pitch must be positive".into());
}
if !(turns.is_finite() && turns > 0.0) {
return Err("sweep_profile_helix: turns must be positive".into());
}
if turns > 64.0 {
return Err("sweep_profile_helix: turns must be at most 64".into());
}
let mut samples = Vec::new();
for curve in profile {
let [start, end] = curve.domain()?;
for sample in 0..16 {
samples.push(curve.evaluate(start + (end - start) * sample as f64 / 16.0)?);
}
}
if !samples.is_empty() {
let mut centroid = Vec3::default();
for point in &samples {
centroid = centroid.add(*point);
}
let origin = centroid.scale(1.0 / samples.len() as f64);
let extent = samples
.iter()
.map(|point| point.sub(origin).length())
.fold(0.0, f64::max);
let c = pitch / TAU; let curvature_radius = (helix_radius * helix_radius + c * c) / helix_radius;
if extent >= curvature_radius {
return Err(format!(
"sweep_profile_helix: profile extent {extent:.6} reaches the helix \
curvature radius {curvature_radius:.6}; the tube would fold through \
itself — increase the helix radius or pitch, or shrink the profile"
));
}
if turns >= 1.0 {
let circumference = TAU * helix_radius;
let turn_length = (circumference * circumference + pitch * pitch).sqrt();
let coil_gap = pitch * circumference / turn_length;
if coil_gap <= 2.0 * extent {
return Err(format!(
"sweep_profile_helix: consecutive turns would self-intersect — \
coil gap {coil_gap:.6} does not clear the profile diameter {:.6}; \
increase the pitch or shrink the profile",
2.0 * extent
));
}
}
}
let path = fit_helix_curve(
axis_origin,
w,
None,
helix_radius,
helix_radius,
pitch,
turns,
0.0,
false,
)
.map_err(|error| format!("sweep_profile_helix: helix path fit failed: {error}"))?;
let stations = ((turns * 32.0).ceil() as usize).clamp(32, 1024);
sweep_profile_along_path_stations(
profile,
&path,
name,
stations,
0.0,
None,
SectionPlacement::Transplant,
)
.map_err(|error| format!("sweep_profile_helix: {error}"))
}