use super::*;
use super::stations::{
StationBudget, StationParam, SweepStationReport, SWEEP_DEFAULT_TOLERANCE, SWEEP_MAX_STATIONS,
SWEEP_REFINEMENT_ROUNDS,
};
pub fn sweep_profile_along_path(
profile: &[NurbsCurve],
path: &NurbsCurve,
name: Option<&str>,
) -> Result<BrepSolid, KernelRefusal> {
sweep_profile_along_path_stations(
profile,
path,
name,
path_turning_stations(path),
0.0,
None,
SectionPlacement::Transplant,
SWEEP_DEFAULT_TOLERANCE,
false, )
.map(|swept| swept.solid)
}
pub fn sweep_profile_twisted(
profile: &[NurbsCurve],
path: &NurbsCurve,
twist_angle: f64,
name: Option<&str>,
) -> Result<BrepSolid, KernelRefusal> {
use std::f64::consts::{FRAC_PI_2, TAU};
if !twist_angle.is_finite() {
return Err(KernelRefusal::input(
KernelStage::Collect,
"twist_finite",
"sweep_profile_twisted: twist angle must be finite",
));
}
const MAX_TURNS: f64 = 16.0;
if twist_angle.abs() > MAX_TURNS * TAU {
return Err(KernelRefusal::unsupported(
KernelStage::Collect,
"twist_cap",
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,
SWEEP_DEFAULT_TOLERANCE,
true,
)
.map(|swept| swept.solid)
.map_err(|error| error.with_message(|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, KernelRefusal> {
sweep_profile_along_path_stations(
profile,
path,
name,
path_turning_stations(path),
0.0,
Some(anchor),
SectionPlacement::Transplant,
SWEEP_DEFAULT_TOLERANCE,
true,
)
.map(|swept| swept.solid)
}
pub fn sweep_profile_twisted_anchored(
profile: &[NurbsCurve],
path: &NurbsCurve,
twist_angle: f64,
name: Option<&str>,
anchor: ProfileAnchor,
) -> Result<BrepSolid, KernelRefusal> {
use std::f64::consts::{FRAC_PI_2, TAU};
if !twist_angle.is_finite() {
return Err(KernelRefusal::input(
KernelStage::Collect,
"twist_finite",
"sweep_profile_twisted: twist angle must be finite",
));
}
const MAX_TURNS: f64 = 16.0;
if twist_angle.abs() > MAX_TURNS * TAU {
return Err(KernelRefusal::unsupported(
KernelStage::Collect,
"twist_cap",
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,
SWEEP_DEFAULT_TOLERANCE,
true,
)
.map(|swept| swept.solid)
.map_err(|error| error.with_message(|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, KernelRefusal> {
sweep_profile_along_chain_reported_core(
profile,
path,
twist_angle,
name,
anchor,
placement_mode,
corner_advice,
None,
false, )
.map(|swept| swept.solid)
}
#[derive(Debug)]
pub struct SweptChain {
pub solid: BrepSolid,
pub report: Option<SweepStationReport>,
}
impl SweptChain {
fn exact(solid: BrepSolid) -> Self {
Self {
solid,
report: None,
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn sweep_profile_along_chain_reported(
profile: &[NurbsCurve],
path: &SweepPath,
twist_angle: f64,
name: Option<&str>,
anchor: Option<ProfileAnchor>,
placement_mode: SectionPlacement,
corner_advice: &str,
tolerance: Option<f64>,
) -> Result<SweptChain, KernelRefusal> {
sweep_profile_along_chain_reported_core(profile, path, twist_angle, name, anchor,
placement_mode, corner_advice, tolerance, true)
}
#[allow(clippy::too_many_arguments)]
fn sweep_profile_along_chain_reported_core(
profile: &[NurbsCurve],
path: &SweepPath,
twist_angle: f64,
name: Option<&str>,
anchor: Option<ProfileAnchor>,
placement_mode: SectionPlacement,
corner_advice: &str,
tolerance: Option<f64>,
measure_report: bool,
) -> Result<SweptChain, KernelRefusal> {
use std::f64::consts::{FRAC_PI_2, TAU};
let tolerance = sweep_tolerance(tolerance)?;
if path.is_empty() {
return Err(KernelRefusal::input(
KernelStage::Collect,
"chain_empty",
"sweepSolid: path chain is empty",
));
}
let envelope_lane = super::envelope::recognize_swept_envelope(profile, path, placement_mode)?
.is_ok();
if !twist_angle.is_finite() {
return Err(KernelRefusal::input(
KernelStage::Collect,
"twist_finite",
if path.curves.len() == 1 && placement_mode == SectionPlacement::Transplant {
"sweep_profile_twisted: twist angle must be finite"
} else {
"sweep_profile_along_chain: twist angle must be finite"
},
));
}
const MAX_TURNS: f64 = 16.0;
if twist_angle.abs() > MAX_TURNS * TAU {
let who = if path.curves.len() == 1 && placement_mode == SectionPlacement::Transplant {
"sweep_profile_twisted"
} else {
"sweep_profile_along_chain"
};
return Err(KernelRefusal::unsupported(
KernelStage::Collect,
"twist_cap",
format!(
"{who}: 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
),
));
}
if let ([single], SectionPlacement::Transplant, false, false) =
(path.curves.as_slice(), placement_mode, path.closed, envelope_lane)
{
let stations = if twist_angle == 0.0 {
path_turning_stations(single)
} else {
let quarter_turns = (twist_angle.abs() / FRAC_PI_2).ceil() as usize;
(quarter_turns * 16).clamp(32, 1024).max(path_turning_stations(single))
};
return sweep_profile_along_path_stations(
profile,
single,
name,
stations,
twist_angle,
anchor,
SectionPlacement::Transplant,
tolerance,
measure_report,
)
.map_err(|error| {
if twist_angle == 0.0 {
error
} else {
error.with_message(|error| format!("sweep_profile_twisted: {error}"))
}
});
}
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,
tolerance,
measure_report,
)
}
fn sweep_tolerance(tolerance: Option<f64>) -> Result<f64, KernelRefusal> {
let tolerance = tolerance.unwrap_or(SWEEP_DEFAULT_TOLERANCE);
if !(tolerance.is_finite() && tolerance > 0.0) {
return Err(KernelRefusal::input(
KernelStage::Collect,
"sweep_tolerance",
format!("sweepSolid: the requested wall tolerance must be a positive length, not {tolerance}"),
));
}
Ok(tolerance)
}
pub fn sweep_profile_along_chain_with_stations(
profile: &[NurbsCurve],
path: &SweepPath,
stations: usize,
corner_advice: &str,
) -> Result<BrepSolid, KernelRefusal> {
sweep_profile_along_chain_with_stations_reported(profile, path, stations, corner_advice, None)
.map(|swept| swept.solid)
}
pub fn sweep_profile_along_chain_with_stations_reported(
profile: &[NurbsCurve],
path: &SweepPath,
stations: usize,
corner_advice: &str,
tolerance: Option<f64>,
) -> Result<SweptChain, KernelRefusal> {
let tolerance = sweep_tolerance(tolerance)?;
sweep_profile_along_chain_stations(
profile,
path,
stations.clamp(2, 1024),
0.0,
None,
SectionPlacement::Transplant,
corner_advice,
CornerPolicy::Refuse,
tolerance,
true,
)
}
pub fn profile_anchor(profile: &[NurbsCurve]) -> Result<ProfileAnchor, KernelRefusal> {
let tolerance = 1e-6;
if profile.len() < 2 {
return Err(KernelRefusal::input(
KernelStage::Collect,
"profile_count",
"sweepSolid: profile needs at least 2 curves forming a closed loop",
));
}
let mut samples = Vec::new();
for (index, curve) in profile.iter().enumerate() {
let [start, end] = curve.domain().or_refuse(KernelStage::Collect, "domain")?;
let next = &profile[(index + 1) % profile.len()];
let next_start = next.domain().or_refuse(KernelStage::Collect, "domain")?[0];
if curve
.evaluate(end).or_refuse(KernelStage::Collect, "evaluate")?
.sub(next.evaluate(next_start).or_refuse(KernelStage::Collect, "evaluate")?)
.length()
> tolerance
{
return Err(KernelRefusal::input(
KernelStage::Collect,
"profile_closed",
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).or_refuse(KernelStage::Collect, "evaluate")?);
}
}
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(|_| {
KernelRefusal::input(
KernelStage::Collect,
"profile_area",
"sweepSolid: profile is degenerate (zero enclosed area)",
)
})?;
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(KernelRefusal::input(
KernelStage::Collect,
"profile_planar",
"sweepSolid: profile is not planar",
));
}
let pu = np.perpendicular().or_refuse(KernelStage::Collect, "perpendicular")?;
let pv = np.cross(pu).normalized().or_refuse(KernelStage::Collect, "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,
tolerance: f64,
measure_report: bool,
) -> Result<SweptChain, KernelRefusal> {
let _ = name;
let mut timing = crate::loft_topology::ConstructionProfile::new("sweep_path");
if stations < 2 {
return Err(KernelRefusal::input(
KernelStage::Collect,
"stations",
"sweepSolid: need at least 2 stations",
));
}
let placement = resolve_placement(profile, anchor, placement_mode)?;
timing.lap("profile");
let chain = std::slice::from_ref(path);
let (samples, budget) = chain_stations(chain, false, SamplerLane::SingleCurve, stations, tolerance)?;
let StationSamples {
points,
tangents,
curvatures,
params,
..
} = &samples;
timing.lap("sampling");
sweep_sections_through_samples(
profile,
points,
tangents,
curvatures,
twist_angle,
placement,
placement_mode,
RingFrame::Open,
None,
None,
StationRun {
chain,
params,
budget,
tolerance,
measure_report,
},
)
}
#[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), KernelRefusal> {
if placement_mode == SectionPlacement::Rigid {
return Ok((profile.to_vec(), placement.normal));
}
let ProfileAnchor { origin, pu, pv, .. } = placement;
let r0 = tangent.perpendicular().or_refuse(KernelStage::Fragment, "perpendicular")?;
let s0 = tangent.cross(r0).normalized().or_refuse(KernelStage::Fragment, "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>>()
.or_refuse(KernelStage::Fragment, "control_point")?;
placed.push(
NurbsCurve::new(curve.degree, curve.knots.clone(), control_points)
.or_refuse(KernelStage::Fragment, "curve_new")?,
);
}
Ok((placed, tangent))
}
fn resolve_placement(
profile: &[NurbsCurve],
anchor: Option<ProfileAnchor>,
placement_mode: SectionPlacement,
) -> Result<ProfileAnchor, KernelRefusal> {
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,
tolerance: f64,
measure_report: bool,
) -> Result<SweptChain, KernelRefusal> {
let chain = path.curves.as_slice();
if chain.is_empty() {
return Err(KernelRefusal::input(
KernelStage::Collect,
"chain_empty",
"sweepSolid: path chain is empty",
));
}
if stations < 2 {
return Err(KernelRefusal::input(
KernelStage::Collect,
"stations",
"sweepSolid: need at least 2 stations",
));
}
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(SweptChain::exact(bodies.remove(0)));
}
return Err(KernelRefusal::unsupported(
KernelStage::Fragment,
"envelope_two_bodies",
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(KernelRefusal::unsupported(
KernelStage::Classify,
"cornered_twist",
"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",
));
}
let (section, section_normal, _) =
mitre_section(profile, placement, placement_mode, path)?;
return super::miter::sweep_section_mitered(
§ion,
section_normal,
path,
twist_angle,
)
.map(SweptChain::exact);
}
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(KernelRefusal::unsupported(
KernelStage::Classify,
"corner",
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()
),
));
}
if placement_mode == SectionPlacement::Transplant
&& twist_angle == 0.0
&& std::env::var("BREP_SWEEP_BEND_GUARD").as_deref() != Ok("0")
{
let (floor, _) = chain_stations(path.curves.as_slice(), path.closed, SamplerLane::Chain, stations, 0.0)?;
let profile_stations = floor_bend_stations(profile, &placement, path, &floor)?;
if let Some(refusal) = tight_bend_refusal(&profile_stations, floor.points.len()) {
return Err(with_envelope_gap(refusal, Some(envelope_gap.as_str())));
}
}
let (samples, budget) = chain_stations(
path.curves.as_slice(),
path.closed,
SamplerLane::Chain,
stations,
tolerance,
)?;
let StationSamples {
points,
tangents,
curvatures,
segments,
params,
} = &samples;
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)),
StationRun {
chain: path.curves.as_slice(),
params,
budget,
tolerance,
measure_report,
},
)
}
#[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, KernelRefusal> {
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>, KernelRefusal> {
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.curves.as_slice(),
path.closed,
SamplerLane::Chain,
stations.clamp(2, 1024),
SWEEP_DEFAULT_TOLERANCE,
)?;
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, KernelRefusal> {
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), KernelRefusal> {
let [t0, t1] = path.curves[0].domain().or_refuse(KernelStage::Collect, "domain")?;
let start = path.curves[0].evaluate(t0).or_refuse(KernelStage::Collect, "evaluate")?;
let tangent = path.curves[0]
.evaluate(t1).or_refuse(KernelStage::Collect, "evaluate")?
.sub(start)
.normalized()
.map_err(|_| {
KernelRefusal::input(
KernelStage::Collect,
"segment_length",
"sweepSolid: path segment 0 has zero length",
)
})?;
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>), KernelRefusal> {
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(|_| {
KernelRefusal::internal(
KernelStage::Classify,
"planar_frame",
"sweepSolid: the closed path's plane normal is parallel to its tangent",
)
})?;
s_axes.push(tangent.cross(r).normalized().or_refuse(KernelStage::Classify, "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), KernelRefusal> {
let stations = points.len();
if stations < 2 {
return Err(KernelRefusal::internal(
KernelStage::Collect,
"station_run",
"sweepSolid: need at least 2 stations",
));
}
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<(), KernelRefusal> {
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(KernelRefusal::internal(
KernelStage::Classify,
"lap_length",
"sweepSolid: the closed path has zero length",
));
}
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>), KernelRefusal> {
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().or_refuse(KernelStage::Classify, "perpendicular")?; s_axes.push(tangents[0].cross(r0).normalized().or_refuse(KernelStage::Classify, "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(|| {
KernelRefusal::internal(
KernelStage::Classify,
"frame_step",
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().or_refuse(KernelStage::Classify, "normalized")?);
r_axes.push(r_next);
}
}
Ok((r_axes, s_axes))
}
fn screw_reference(axis: Vec3, tangent: Vec3, segment: &str, station: usize) -> Result<Vec3, KernelRefusal> {
axis.sub(tangent.scale(axis.dot(tangent)))
.normalized()
.map_err(|_| {
KernelRefusal::ill_posed(
KernelStage::Classify,
"screw_reference",
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) params: Vec<StationParam>,
}
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)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SamplerLane {
Chain,
SingleCurve,
}
fn chain_stations(
chain: &[NurbsCurve],
closed: bool,
lane: SamplerLane,
stations: usize,
tolerance: f64,
) -> Result<(StationSamples, StationBudget), KernelRefusal> {
let position_tolerance = 1e-6;
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().or_refuse(KernelStage::Collect, "domain")?;
if (t1 - t0).abs() <= position_tolerance {
return Err(KernelRefusal::input(
KernelStage::Collect,
"path_domain",
match lane {
SamplerLane::Chain => format!("sweepSolid: path segment {index} has a degenerate domain"),
SamplerLane::SingleCurve => "sweepSolid: path domain is degenerate".to_string(),
},
));
}
let mut length = 0.0;
let mut previous = curve.evaluate(t0).or_refuse(KernelStage::Collect, "evaluate")?;
for sample in 1..=16 {
let point = curve.evaluate(t0 + (t1 - t0) * sample as f64 / 16.0).or_refuse(KernelStage::Collect, "evaluate")?;
length += point.sub(previous).length();
previous = point;
}
domains.push([t0, t1]);
lengths.push(length);
turning.push(match lane {
SamplerLane::Chain => segment_turning(curve, [t0, t1]).map_err(|error| {
KernelRefusal::input(
KernelStage::Classify,
"path_tangent",
format!("sweepSolid: path tangent is degenerate on segment {index}: {error}"),
)
})?,
SamplerLane::SingleCurve => 0.0,
});
}
let total_length: f64 = lengths.iter().sum();
if total_length <= position_tolerance {
return Err(KernelRefusal::input(
KernelStage::Collect,
"path_length",
"sweepSolid: path chain has zero length",
));
}
let floors: Vec<usize> = turning
.iter()
.map(|turn| ((turn / std::f64::consts::FRAC_PI_2) * 16.0).ceil() as usize)
.collect();
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(closed);
let emitted: usize = budget.iter().sum::<usize>() - shared;
if emitted > SWEEP_MAX_STATIONS {
return Err(KernelRefusal::unsupported(
KernelStage::Collect,
"station_cap",
format!(
"sweepSolid: a {}-segment path needs at least {emitted} stations, past the \
{SWEEP_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 per_segment: Vec<Vec<f64>> = domains
.iter()
.zip(&budget)
.map(|([t0, t1], count)| {
(0..*count)
.map(|sample| t0 + (t1 - t0) * sample as f64 / (count - 1) as f64)
.collect()
})
.collect();
let initial = per_segment.iter().map(Vec::len).sum::<usize>() - shared;
let mut budget = StationBudget {
initial,
stations: initial,
rounds: 0,
at_cap: false,
rounds_exhausted: false,
};
let refine = tolerance.is_finite() && tolerance > 0.0 && super::stations::refinement_enabled();
let mut samples = sample_chain(chain, closed, lane, &domains, &per_segment)?;
if refine {
loop {
let deviations = super::stations::spine_deviations(chain, &samples.params, &samples.points, closed)
.map_err(|error| {
KernelRefusal::internal(
KernelStage::Refine,
"station_deviation",
format!("sweepSolid: the station deviation could not be read: {error}"),
)
})?;
if deviations.iter().all(|deviation| *deviation <= tolerance) {
break;
}
if budget.rounds >= SWEEP_REFINEMENT_ROUNDS {
budget.rounds_exhausted = true;
break;
}
let emitted = samples.points.len();
let (inserted, at_cap) = super::stations::refine_parameters(
chain,
&mut per_segment,
&samples.params,
&deviations,
tolerance,
closed,
emitted,
)
.map_err(|error| {
KernelRefusal::internal(
KernelStage::Refine,
"station_refinement",
format!("sweepSolid: the stations could not be refined: {error}"),
)
})?;
budget.at_cap |= at_cap;
if inserted == 0 {
break;
}
budget.rounds += 1;
samples = sample_chain(chain, closed, lane, &domains, &per_segment)?;
if at_cap {
break;
}
}
}
budget.stations = samples.points.len();
Ok((samples, budget))
}
fn sample_chain(
chain: &[NurbsCurve],
closed: bool,
lane: SamplerLane,
domains: &[[f64; 2]],
per_segment: &[Vec<f64>],
) -> Result<StationSamples, KernelRefusal> {
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();
let mut params: Vec<StationParam> = Vec::new();
for (index, curve) in chain.iter().enumerate() {
let [t0, _] = domains[index];
let list = &per_segment[index];
let count = list.len();
let first = usize::from(index > 0);
if first == 1 {
let derivatives = curve.derivatives_small(t0, 2).or_refuse(KernelStage::Collect, "derivatives")?;
let arriving = curvature_vector(derivatives[1], derivatives[2]);
if let Some(departing) = curvatures.last_mut() {
if arriving.length() > departing.length() {
*departing = arriving;
}
}
if let Some(joint) = params.last_mut() {
*joint = StationParam { segment: index, t: t0 };
}
}
let last = count - usize::from(closed && index + 1 == chain.len());
for (sample, &t) in list.iter().enumerate().take(last).skip(first) {
let derivatives = curve.derivatives_small(t, 2).or_refuse(KernelStage::Collect, "derivatives")?;
let tangent = derivatives[1].normalized().map_err(|_| {
KernelRefusal::input(
KernelStage::Classify,
"path_tangent",
match lane {
SamplerLane::Chain => format!("sweepSolid: path tangent is degenerate on segment {index}"),
SamplerLane::SingleCurve => format!("sweepSolid: path tangent is degenerate at station {sample}"),
},
)
})?;
points.push(derivatives[0]);
tangents.push(tangent);
curvatures.push(curvature_vector(derivatives[1], derivatives[2]));
segments.push(index);
params.push(StationParam { segment: index, t });
}
}
if closed {
if let (Some(curve), Some(domain)) = (chain.last(), domains.last()) {
let derivatives = curve.derivatives_small(domain[1], 2).or_refuse(KernelStage::Collect, "derivatives")?;
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,
params,
})
}
fn ring_normal(normal: Vec3, tangents: &[Vec3]) -> Result<Vec3, KernelRefusal> {
for (index, tangent) in tangents.iter().enumerate() {
if tangent.cross(normal).length() < 0.5 {
return Err(KernelRefusal::internal(
KernelStage::Classify,
"ring_normal",
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(|_| {
KernelRefusal::internal(
KernelStage::Classify,
"ring_normal_degenerate",
"sweepSolid: the closed path's plane normal is degenerate",
)
})
}
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)>, KernelRefusal> {
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().or_refuse(KernelStage::Classify, "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).or_refuse(KernelStage::Classify, "evaluate")?.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>, KernelRefusal> {
let placement = resolve_placement(profile, None, SectionPlacement::Transplant)?;
let (samples, _) = chain_stations(
path.curves.as_slice(),
path.closed,
SamplerLane::Chain,
stations.clamp(2, 1024),
0.0,
)?;
floor_bend_stations(profile, &placement, path, &samples)
}
fn floor_bend_stations(
profile: &[NurbsCurve],
placement: &ProfileAnchor,
path: &SweepPath,
samples: &StationSamples,
) -> Result<Vec<BendStation>, KernelRefusal> {
let closure = ring_frame(path, &samples.tangents)?;
let untwisted = ClosingTwist {
twist: 0.0,
shift: 0,
};
let (r_axes, s_axes, _) = station_frames(&samples.points, &samples.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(&samples.points, &samples.curvatures, &axes, &offsets))
}
fn with_envelope_gap(refusal: KernelRefusal, envelope_gap: Option<&str>) -> KernelRefusal {
match envelope_gap {
Some(gap) if !gap.is_empty() => refusal.with_message(|refusal| {
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,
}
}
pub const SWEEP_TIGHT_BEND_REFUSAL: &str = "sweepSolid: the path bends TIGHTER than the section";
pub(crate) const SWEEP_TIGHT_BEND_WHAT: &str = "tight_bend";
pub(crate) fn is_sweep_tight_bend(refusal: &KernelRefusal) -> bool {
matches!(
&refusal.class,
crate::RefusalClass::UnsupportedGeometry { what } if what == SWEEP_TIGHT_BEND_WHAT
)
}
fn tight_bend_refusal(profile: &[BendStation], stations: usize) -> Option<KernelRefusal> {
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(KernelRefusal::unsupported(
KernelStage::Classify,
SWEEP_TIGHT_BEND_WHAT,
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,
),
))
}
struct StationRun<'a> {
chain: &'a [NurbsCurve],
params: &'a [StationParam],
budget: StationBudget,
tolerance: f64,
measure_report: bool,
}
#[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)>,
run: StationRun<'_>,
) -> Result<SweptChain, KernelRefusal> {
let mut timing = crate::loft_topology::ConstructionProfile::new("sweep_sections");
let tolerance = 1e-6;
let stations = points.len();
if stations < 2 {
return Err(KernelRefusal::internal(
KernelStage::Collect,
"station_run",
"sweepSolid: need at least 2 stations",
));
}
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(KernelRefusal::input(
KernelStage::Collect,
"path_length",
"sweepSolid: path has zero length; cannot distribute the twist",
));
}
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(KernelRefusal::ill_posed(
KernelStage::Classify,
"near_closed_path",
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"
),
));
}
}
timing.lap("frames");
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);
let mut centres: Vec<Vec3> = Vec::with_capacity(stations);
let mut axes_1: Vec<Vec3> = Vec::with_capacity(stations);
let mut axes_2: Vec<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)))
}
};
{
let centre = place(origin);
centres.push(centre);
axes_1.push(place(origin.add(pu)).sub(centre));
axes_2.push(place(origin.add(pv)).sub(centre));
}
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,
).or_refuse(KernelStage::Fragment, "curve_new")?);
}
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(KernelRefusal::unsupported(
KernelStage::Classify,
"straddles_axis",
"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",
));
}
if most_positive <= noise && most_negative >= -noise {
return Err(KernelRefusal::input(
KernelStage::Classify,
"no_advance",
"sweepSolid: the path does not advance through the profile — it runs inside the \
profile plane, so the sweep encloses no volume",
));
}
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(KernelRefusal::input(
KernelStage::Classify,
"sliver",
"sweepSolid: the path is nearly parallel to the profile plane, which sweeps a \
sliver rather than a solid",
));
}
}
}
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(with_envelope_gap(refusal, envelope_gap));
}
}
timing.lap("sections_preflight");
let solid = match closure {
RingFrame::Planar(_) | RingFrame::Spatial => {
crate::loft_profile_brep_closed_shifted(§ions, requested.shift).map_err(|error| {
error.with_message(|error| {
format!("sweepSolid: closed loft through swept sections failed: {error}")
})
})?
}
RingFrame::Open => loft_profile_brep(§ions).map_err(|error| {
error.with_message(|error| {
format!("sweepSolid: loft through swept sections failed: {error}")
})
})?,
};
timing.lap("loft");
if !run.measure_report {
return Ok(SweptChain { solid, report: None });
}
let moments = super::stations::section_moments(profile, origin, pu, pv).map_err(|error| {
KernelRefusal::internal(
KernelStage::Refine,
"section_moments",
format!("sweepSolid: the section's area could not be read: {error}"),
)
})?;
let (pu_in_frame, pv_in_frame) = if rigid {
([pu.dot(t0), pu.dot(r0), pu.dot(s0)], [pv.dot(t0), pv.dot(r0), pv.dot(s0)])
} else {
([0.0, 1.0, 0.0], [0.0, 0.0, 1.0])
};
let report = super::stations::station_report(&super::stations::PlacedRun {
chain: run.chain,
params: run.params,
points,
tangents,
axes: &placed_axes,
centres: ¢res,
e1: &axes_1,
e2: &axes_2,
pu_in_frame,
pv_in_frame,
closed: closure.is_ring(),
sections: §ions,
moments,
reach: super::stations::section_reach(profile, origin),
shift: requested.shift,
budget: run.budget,
tolerance: run.tolerance,
})?;
timing.lap("approximation");
Ok(SweptChain {
solid,
report: Some(report),
})
}
#[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>), KernelRefusal> {
use std::f64::consts::TAU;
let w = axis_direction.normalized().map_err(|_| {
KernelRefusal::input(
KernelStage::Collect,
"helix_axis",
"helix: axis direction is degenerate",
)
})?;
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(KernelRefusal::input(
KernelStage::Collect,
"helix_finite",
format!("helix: {name} must be a finite number"),
));
}
}
if start_radius < 0.0 || end_radius < 0.0 {
return Err(KernelRefusal::input(
KernelStage::Collect,
"helix_radius",
"helix: radius must not be negative",
));
}
if pitch < 0.0 {
return Err(KernelRefusal::input(
KernelStage::Collect,
"helix_pitch",
"helix: pitch must not be negative",
));
}
if turns <= 0.0 {
return Err(KernelRefusal::input(
KernelStage::Collect,
"helix_turns",
"helix: turns must be positive",
));
}
if turns > 256.0 {
return Err(KernelRefusal::input(
KernelStage::Collect,
"helix_turns_limit",
"helix: turns must be at most 256",
));
}
if start_radius == 0.0 && end_radius == 0.0 && pitch == 0.0 {
return Err(KernelRefusal::input(
KernelStage::Collect,
"helix_point",
"helix: zero radius and zero pitch describe a single point",
));
}
let u = match reference {
Some(reference) => {
let radial = reference.sub(w.scale(reference.dot(w)));
radial
.normalized()
.or_else(|_| w.perpendicular())
.or_refuse(KernelStage::Collect, "perpendicular")?
}
None => w.perpendicular().or_refuse(KernelStage::Collect, "perpendicular")?,
};
let v = w.cross(u).normalized().or_refuse(KernelStage::Collect, "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, KernelRefusal> {
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).or_refuse(KernelStage::Refine, "interpolate")
}
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, KernelRefusal> {
use std::f64::consts::TAU;
let w = axis_direction.normalized().map_err(|_| {
KernelRefusal::input(
KernelStage::Collect,
"helix_axis",
"sweep_profile_helix: axis direction is degenerate",
)
})?;
if !(helix_radius.is_finite() && helix_radius > 0.0) {
return Err(KernelRefusal::input(
KernelStage::Collect,
"helix_radius",
"sweep_profile_helix: helix radius must be positive",
));
}
if !(pitch.is_finite() && pitch > 0.0) {
return Err(KernelRefusal::input(
KernelStage::Collect,
"helix_pitch",
"sweep_profile_helix: pitch must be positive",
));
}
if !(turns.is_finite() && turns > 0.0) {
return Err(KernelRefusal::input(
KernelStage::Collect,
"helix_turns",
"sweep_profile_helix: turns must be positive",
));
}
if turns > 64.0 {
return Err(KernelRefusal::unsupported(
KernelStage::Collect,
"helix_turns_cap",
"sweep_profile_helix: turns must be at most 64",
));
}
let mut samples = Vec::new();
for curve in profile {
let [start, end] = curve.domain().or_refuse(KernelStage::Collect, "domain")?;
for sample in 0..16 {
samples.push(curve.evaluate(start + (end - start) * sample as f64 / 16.0).or_refuse(KernelStage::Collect, "evaluate")?);
}
}
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(KernelRefusal::unsupported(
KernelStage::Classify,
"helix_fold",
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(KernelRefusal::unsupported(
KernelStage::Classify,
"coil_collision",
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| {
error.with_message(|error| format!("sweep_profile_helix: helix path fit failed: {error}"))
})?;
let per_turn = if switch!("BREP_HELIX_DENSE_STATIONS", true) { 64.0 } else { 32.0 };
let stations = ((turns * per_turn).ceil() as usize).clamp(32, 1024);
sweep_profile_along_path_stations(
profile,
&path,
name,
stations,
0.0,
None,
SectionPlacement::Transplant,
SWEEP_DEFAULT_TOLERANCE,
true,
)
.map(|swept| swept.solid)
.map_err(|error| error.with_message(|error| format!("sweep_profile_helix: {error}")))
}