kernel: twisted and scaled sweeps round corners by the plain sweep's corner model

A sweep that twists or scales refused any path whose direction jumps by more
than 1e-3 rad, a branch on the path's shape. It now takes the corner model of
the plain sweep: each smooth run is swept through stations by the laws a smooth
path has (twist and scale running with the length along the whole path, each
run going on from the section the last ended with), and the runs meet at each
corner in a mitre. Turning with the path, each run goes on past its corners as
a prism of its section there (the laws hold still across the mitre: the two
runs' sections meet on the plane halving the corner only when both are the
corner's section) and is cut back to that plane, then turned through the
corner onto the next run; only moving along the path, the runs meet at the
corner's section. The mitre plane is one function for both (mitre_of).

Refused, with the reason, where a control has no meaning at a sharp corner: an
orientation direction (it turns the section by its own turn, not the corner's,
so the two runs' sections don't meet), guide rails (their sections jump where
the path turns), and a closed path with corners (a twist or scale keeps the
last section off the first).

(not built: base in progress; no error in these files)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017itGh5fpMEousnZ91cYcVt
This commit is contained in:
jyviko
2026-10-22 07:39:21 -04:00
parent f7daf61249
commit caa5ed0e63
2 changed files with 186 additions and 18 deletions
-9
View File
@@ -1240,15 +1240,6 @@ fn smlib_sweep(plane: &Plane, region: &Region2, path: &[PathSeg], ctl: &crate::S
if path.iter().any(|s| s.tangents().is_none()) {
return Err(KernelError::Invalid("a path segment has no direction".into()));
}
let controlled = ctl.varies() || matches!(ctl.orientation, crate::SweepOrientation::Direction(_));
if controlled {
let ends: Vec<(Vec3, Vec3)> = path.iter().filter_map(PathSeg::tangents).collect();
if ends.windows(2).any(|w| w[0].1.dot(w[1].0) < (1e-3f64).cos()) {
return Err(crate::smlib_sweeps::refuse(
"sweep with a twist, an end scale, guide rails or an orientation direction along a path with corners",
));
}
}
let translate = match ctl.orientation {
_ if ctl.varies() => return crate::smlib_sweeps::sweep_controlled(plane, &oriented(region)?, path, ctl),
crate::SweepOrientation::Perpendicular => false,
+186 -9
View File
@@ -703,20 +703,13 @@ mod imp {
// first meet in a mitre too.
let closed = end.dist(start) <= 1e-6;
let closing = (closed && last_t.dot(first_t) < (1e-3f64).cos()).then_some(first_t);
let mitre = |t1: Vec3, t2: Vec3| -> Result<(Vec3, f64)> {
let turn = t1.dot(t2).clamp(-1.0, 1.0).acos();
if turn > std::f64::consts::PI - 1e-3 {
return Err(KernelError::Invalid("the path turns back on itself at a corner".into()));
}
Ok(((t1 + t2).normalized().ok_or_else(|| KernelError::Invalid("path corner".into()))?, turn))
};
let mut pose = *plane;
let mut body: Option<Brep> = None;
let mut inside = Vec::new();
// The mitre the next run starts from: its point, its normal (along the run) and the turn.
let mut entry: Option<(Vec3, Vec3, f64)> = match closing {
Some(_) if !translate => {
let (m, turn) = mitre(last_t, first_t)?;
let (m, turn) = mitre_of(last_t, first_t)?;
Some((start, m, turn))
}
_ => None,
@@ -733,7 +726,7 @@ mod imp {
None => closing,
};
// The plane halving the corner at the run's end, and the turn there.
let exit = next.map(|t2| mitre(t1, t2)).transpose()?;
let exit = next.map(|t2| mitre_of(t1, t2)).transpose()?;
// Each piece's sweep says where it left the profile: half-way along it each wall lies
// over its piece, and at its end (less any straight lead-out past the corner) the
// profile is where the next run takes it on.
@@ -882,6 +875,10 @@ mod imp {
/// exactly the profile's image, and SMLib skins the walls through them. Round a closed path the
/// last section has to land on the first, and the ends are joined.
pub(crate) fn sweep_controlled(plane: &Plane, region: &NumberedRegion, path: &[crate::PathSeg], ctl: &crate::SweepControl) -> Result<Body> {
let runs = smooth_runs(path)?;
if runs.len() > 1 {
return cornered_controlled(plane, region, path, &runs, ctl);
}
let pieces = pieces(region.loops());
let c = curves(plane, &pieces)?;
let start = path.first().map(crate::PathSeg::start).ok_or_else(|| KernelError::Invalid("the path needs 1…1000 segments".into()))?;
@@ -927,6 +924,186 @@ mod imp {
with_history(body, &roles, &pieces)
}
/// `x` moved by `a`.
fn affine_apply(a: &Affine, x: Vec3) -> Vec3 {
let [c0, c1, c2] = a.m;
let v = |q: [f64; 3]| Vec3::new(q[0], q[1], q[2]);
v(c0) * x.x + v(c1) * x.y + v(c2) * x.z + v(a.t)
}
/// `v` turned by `a`'s linear part.
fn affine_turn(a: &Affine, v: Vec3) -> Vec3 {
affine_apply(a, v) - affine_apply(a, Vec3::ZERO)
}
/// `a` after `b`: x goes to a(b(x)).
fn affine_after(a: &Affine, b: &Affine) -> Affine {
let col = |c: [f64; 3]| p(affine_turn(a, Vec3::new(c[0], c[1], c[2])));
Affine { m: [col(b.m[0]), col(b.m[1]), col(b.m[2])], t: p(affine_apply(a, Vec3::new(b.t[0], b.t[1], b.t[2]))) }
}
/// The rigid turn by `angle` about the unit `k` through `center`, as an affine map.
fn affine_turned(center: Vec3, k: Vec3, angle: f64) -> Affine {
let col = |e: Vec3| p(turned(e, k, angle));
Affine { m: [col(Vec3::X), col(Vec3::Y), col(Vec3::Z)], t: p(center - turned(center, k, angle)) }
}
fn affine_moved(a: &Affine, by: Vec3) -> Affine {
Affine { m: a.m, t: p(Vec3::new(a.t[0], a.t[1], a.t[2]) + by) }
}
const IDENTITY: Affine = Affine { m: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], t: [0.0; 3] };
/// A sweep that twists or scales along a path with corners, by the corner model of the plain
/// sweep ([`cornered`]): each smooth run of the path is swept through stations by the same
/// laws as a smooth path (the twist and the scale running with the length along the whole
/// path, each run going on from the section the last one ended with), and the runs meet at
/// each corner in a mitre. Turning with the path, each run is carried on straight past its
/// corners as a prism of its section there (the laws hold still across a mitre: the two runs'
/// sections meet on the plane halving the corner only when both are the corner's section) and
/// cut back to that plane; only moving along the path, the runs meet at the corner's section.
///
/// Defined only where the laws keep their meaning at a sharp corner: an orientation direction
/// turns the profile there by a turn of its own, not the corner's, so its sections don't meet
/// in a mitre; guide rails place each section by where they cross its plane, which jumps at the
/// corner; and round a closed path the last section has to land on the first, which a twist or
/// scale breaks. Those are refused.
fn cornered_controlled(
plane: &Plane,
region: &NumberedRegion,
path: &[crate::PathSeg],
runs: &[std::ops::Range<usize>],
ctl: &crate::SweepControl,
) -> Result<Body> {
let translate = match ctl.orientation {
_ if !ctl.rails.is_empty() => {
return Err(refuse("sweep along guide rails round a sharp corner of the path (the rails' sections jump there)"));
}
crate::SweepOrientation::Direction(_) => {
return Err(refuse(
"sweep kept at an orientation direction round a sharp corner of the path (its sections turn by their own turn there, not the corner's, and don't meet)",
));
}
crate::SweepOrientation::Perpendicular => false,
crate::SweepOrientation::Parallel => true,
};
let pieces = pieces(region.loops());
let c = curves(plane, &pieces)?;
let bad = || KernelError::Invalid("a path segment has no direction".into());
let start = path.first().map(crate::PathSeg::start).ok_or_else(|| KernelError::Invalid("empty path".into()))?;
let end = path.last().map(crate::PathSeg::end).ok_or_else(|| KernelError::Invalid("empty path".into()))?;
let lens: Vec<f64> = path.iter().map(seg_len).collect();
let total: f64 = lens.iter().sum();
if !(total > 1e-9 && total.is_finite()) {
return Err(KernelError::Invalid("the sweep path has no length".into()));
}
if end.dist(start) <= 1e-6 {
return Err(refuse(
"sweep with a twist or an end scale round a closed path with corners (its last section would have to land on its first)",
));
}
let reach = pieces
.iter()
.flat_map(|x| (0..=8).map(move |i| x.seg.point_at(f64::from(i) / 8.0)))
.map(|q| plane.to_world(q).dist(start) * ctl.scale.max(1.0))
.fold(0.0f64, f64::max);
let size = 4.0 * (total + reach) + 1.0;
let law = |u: f64| 1.0 + (ctl.scale - 1.0) * u;
let n0 = plane.normal().normalized().ok_or_else(|| KernelError::Invalid("profile plane".into()))?;
// The map carrying the profile as drawn onto the section where the next run starts, the
// mitre that run starts from (its point, its normal along the run, its turn) and how far
// along the path it starts.
let mut at_start = IDENTITY;
let mut entry: Option<(Vec3, Vec3, f64)> = None;
let mut before = 0.0f64;
let mut body: Option<Brep> = None;
let mut inside = Vec::new();
let mut last_map = IDENTITY;
for (ri, run) in runs.iter().enumerate() {
let segs = path.get(run.clone()).ok_or_else(|| KernelError::Invalid("path run".into()))?;
let length: f64 = lens.get(run.clone()).ok_or_else(|| KernelError::Invalid("path run".into()))?.iter().sum();
let (u0, u1) = (before / total, (before + length) / total);
let (Some(first), Some(last)) = (segs.first(), segs.last()) else { return Err(KernelError::Invalid("path run".into())) };
let ((t0, _), (_, t1)) = (first.tangents().ok_or_else(bad)?, last.tangents().ok_or_else(bad)?);
let (a0, b1) = (first.start(), last.end());
// The run's own laws on its section at the start: the twist over its length, the scale
// from where the last run left it to where this one ends.
let run_ctl = crate::SweepControl {
orientation: ctl.orientation,
twist: ctl.twist * (u1 - u0),
scale: law(u1) / law(u0),
rails: Vec::new(),
rail_fit: ctl.rail_fit,
};
let turn: f64 = segs.iter().map(path_turn).sum::<f64>() + run_ctl.twist.abs();
let n = ((turn / (std::f64::consts::PI / 180.0)).ceil() as usize).clamp(MIN_STATIONS, MAX_STATIONS);
let (pts, tangents) = path_samples(segs, n)?;
let normal = affine_turn(&at_start, n0).normalized().ok_or_else(|| KernelError::Invalid("the sweep's section is degenerate".into()))?;
let pose = Plane::from_normal(a0, normal).ok_or_else(|| KernelError::Invalid("the sweep's section is degenerate".into()))?;
let st = crate::sweep_path::controlled_stations(&pose, &pts, &tangents, &[], &run_ctl)?;
let maps: Vec<Affine> = st
.iter()
.map(|s| {
let [a, b, cc] = s.m;
affine_after(&Affine { m: [p(a), p(b), p(cc)], t: p(s.at - s.apply(a0)) }, &at_start)
})
.collect();
let (Some(&first_map), Some(&end_map)) = (maps.first(), maps.last()) else { return Err(KernelError::Invalid("sweep path".into())) };
let mid = maps.get(maps.len() / 2).copied().unwrap_or(end_map);
inside.extend(seeds(plane, &pieces, |q| affine_apply(&mid, q)));
let mut piece = Brep::sweep_stations(&c, &maps, Ends::Capped).map_err(|e| failed("sweep", e))?;
let next = runs.get(ri + 1).and_then(|r| path.get(r.start)).map(|s| s.tangents().ok_or_else(bad).map(|t| t.0)).transpose()?;
let exit = next.map(|t2| mitre_of(t1, t2)).transpose()?;
if !translate {
// On past each mitre, the run's section there held still.
let over = |turn: f64| reach * (turn * 0.5).tan() * 1.1 + reach * 1e-3 + 1e-6;
let prism = |map: Affine, toward: Vec3, by: f64| -> Result<Brep> {
Brep::sweep_stations(&c, &[map, affine_moved(&map, toward * by)], Ends::Capped).map_err(|e| failed("sweep corner", e))
};
if let Some((at, m, turn)) = entry {
let lead = prism(affine_moved(&first_map, t0 * -over(turn)), t0, over(turn))?;
piece = combined(&piece, &lead, solidcraft_smlib::BooleanOp::Union, "sweep corner")?;
piece = combined(&piece, &half_space(at, -m, size)?, solidcraft_smlib::BooleanOp::Subtract, "sweep mitre")?;
}
if let Some((m, turn)) = exit {
let tail = prism(end_map, t1, over(turn))?;
piece = combined(&piece, &tail, solidcraft_smlib::BooleanOp::Union, "sweep corner")?;
piece = combined(&piece, &half_space(b1, m, size)?, solidcraft_smlib::BooleanOp::Subtract, "sweep mitre")?;
}
}
body = Some(match body {
None => piece,
Some(b) => combined(&b, &piece, solidcraft_smlib::BooleanOp::Union, "sweep corner")?,
});
// The section at the run's end, turned through the corner onto the next run.
last_map = end_map;
at_start = end_map;
entry = None;
if let (Some(t2), Some((m, turn)), false) = (next, exit, translate) {
let k = t1.cross(t2).normalized().ok_or_else(|| KernelError::Invalid("path corner".into()))?;
at_start = affine_after(&affine_turned(b1, k, turn), &end_map);
entry = Some((b1, m, turn));
}
before += length;
}
let brep = body.ok_or_else(|| KernelError::Invalid("empty path".into()))?;
let body = body_of(&brep, "sweep")?;
let end_plane = Plane::new(affine_apply(&last_map, plane.origin), affine_turn(&last_map, plane.x), affine_turn(&last_map, plane.y))
.ok_or_else(|| KernelError::Invalid("the sweep's end section is degenerate".into()))?;
let roles = roles(&body, plane, Some(&end_plane), &seeds(plane, &pieces, |q| q), &inside, false)?;
with_history(body, &roles, &pieces)
}
/// The plane halving the corner from direction `t1` to `t2` (its unit normal) and the turn
/// there; a path that turns back on itself has none.
fn mitre_of(t1: Vec3, t2: Vec3) -> Result<(Vec3, f64)> {
let turn = t1.dot(t2).clamp(-1.0, 1.0).acos();
if turn > std::f64::consts::PI - 1e-3 {
return Err(KernelError::Invalid("the path turns back on itself at a corner".into()));
}
Ok(((t1 + t2).normalized().ok_or_else(|| KernelError::Invalid("path corner".into()))?, turn))
}
/// A loft through `sections` (each a closed loop of pieces, all re-split to the same count
/// and lined up), `first` the index of the section given first (its cap is the start cap).
pub(crate) fn loft(sections: &[(Plane, Vec<Piece>)], first: usize) -> Result<Body> {