Merge pull request #1479 from brett19/fix/revolve-inside-out-cylinder

fix: revolve inside-out cylinder keeps its axis (fixes #1463)
This commit is contained in:
bherbruck
2026-10-29 07:37:40 +00:00
committed by GitHub
9 changed files with 5043 additions and 51 deletions
+2
View File
@@ -195,6 +195,8 @@ pub struct UpstreamFixes {
pub flat_corners_test_bits: bool,
/// `SmCone::RebuildSTEPFromNURBParameters` gives a cylinder the mean of near-equal radii (semi-angle 0) (WR-385, #713).
pub cylinder_rebuild_mean_radius: bool,
/// `SmCylinder::IsNurbSurfaceCylinder` gives an inside-out NURB (its iso circles turning against the line through their centres) the frame `IsNurbSurfaceCone` gives one: Z against the NURB's linear direction, origin at its top-knot circle; upstream keeps the bottom-knot circle and the NURB's direction, so the cylinder's Z is reversed and its STEP evaluation is mirrored in height (WR-496, #1463).
pub nurb_cylinder_inside_out_frame: bool,
/// `SweepProfilesAlongPath` asks for caps only when the profile closes (WR-400, #837).
pub planar_path_uncapped_open_profiles: bool,
/// A rotational sweep (whole or partial turn) makes each line and circular arc of its profile its cylinder, cone, sphere or torus, not only a one-curve profile's (WR-448, #545; SolidCraft change, tangency-labs/smlib 0b143f7).
+20 -3
View File
@@ -67,7 +67,9 @@
//! where a `SER` call returns its status (a truthy result without cylinder) and
//! `Some` where upstream returns `TRUE` with a cylinder; its NURB is a copy of the
//! tested one, as upstream. A tested surface without NURB is `SM_ERR_NULL_POINTER`
//! (upstream copies a null NURB).
//! (upstream copies a null NURB). With the opt-in
//! `Kernel::fixes.nurb_cylinder_inside_out_frame` (WR-496, #1463) an inside-out
//! NURB gets `IsNurbSurfaceCone`'s frame (top-knot circle, reversed Z).
//! - **Recursion.** None of these functions recurses.
//! - **Debug code.** The `SM_DEBUG_CODE` checks and drawing are omitted.
@@ -755,7 +757,11 @@ impl Cone {
/// corner|)` for distances and radii (upstream default scale `1`). The new bounded
/// cylinder ([`Cone::is_cylinder`]) keeps a copy of the tested NURB as its own, its
/// polar converter rebuilt from it and its generator reparameterised to the
/// linear direction's NURB interval.
/// linear direction's NURB interval. Upstream keeps an inside-out NURB's frame
/// (origin the bottom-knot circle, Z along the linear direction), which reverses
/// the cylinder's axis; with `k.fixes.nurb_cylinder_inside_out_frame` (WR-496,
/// #1463) the frame is the one [`Self::is_nurb_surface_cone`] gives it: origin the
/// top-knot circle, Z against the linear direction.
///
/// # Errors
///
@@ -851,6 +857,17 @@ impl Cone {
let inside_out = z_axis.dot(bot.frame.z_axis()) < 0.0;
// Not upstream (WR-496, #1463, opt-in): an inside-out NURB's STEP frame points
// against the NURB's linear direction from its top-knot circle, as
// IsNurbSurfaceCone builds it; upstream ("Do some correction here") keeps the
// bottom-knot circle and the NURB's direction, reversing the cylinder's Z
let flip = inside_out && k.fixes.nurb_cylinder_inside_out_frame;
let (origin, z_axis) = if flip {
(center_top, -z_axis)
} else {
(center_bot, z_axis)
};
let y_axis = z_axis.cross(x_axis);
// Try to eliminate some numerical inaccurracies (the top radius is not used
// further)
@@ -860,7 +877,7 @@ impl Cone {
}
let mut cylinder = Self::new_cylinder_bounded(
center_bot,
origin,
x_axis,
y_axis,
bot.radius,
@@ -16,7 +16,9 @@
//!
//! Deviations: not in upstream. The surface is laid out as `sm_CreateRotSrf`'s B-spline
//! surface of revolution: `u` the turn (`0` at the curve, rising with the rotation about
//! the axis), `v` the curve from its start to its end. It is only returned when its
//! the axis), `v` the curve from its start to its end; for a line falling along the axis,
//! the reversed line's (`v` from its end), its cylinder or cone built from the low end
//! and never inside out, as 9853e54's `AnalyticSurface` (#1463). It is only returned when its
//! corners, the curve's start tangent and the turn's sense agree with that layout, so a
//! caller can swap it in for the B-spline and keep the trims and loop the B-spline
//! layout gives. Any other curve, or a construction that fails, gives `None` and the
@@ -29,6 +31,7 @@ use crate::geom::geom_utility::p1::{line_closest_point, line_point_distance};
use crate::geom::vector3d::{Point3, Vec3};
use crate::kernel::Kernel;
use crate::math::{EFF_ZERO, PI};
use crate::nurbs::b_spline_curve::BSplineCurve;
use crate::nurbs::b_spline_surface::SurfParamType;
use crate::nurbs::b_spline_surface_n_lib::p2::EvaluateFlags;
use crate::surface::cone::Cone;
@@ -98,20 +101,32 @@ pub fn analytic_revolution(
0.0
};
let tol = tol.min(FIT_REL * (1.0 + size));
let Some(Some(candidate)) =
let Some(Some((candidate, reversed))) =
ignored(candidate(k, curve, base_pt, axis, angle_deg, tol, axis_tol))?
else {
return Ok(None);
};
let made;
let reference = if let Some(surface) = reference {
surface
} else {
let Some(surface) = ignored(create_rot_srf(k, curve, base_pt, axis, angle_deg))? else {
return Ok(None);
};
made = Surface::from(surface);
&made
let reference = match reference {
Some(surface) if !reversed => surface,
_ => {
// a line falling along the axis: laid out as the reversed line's B-spline
let made_srf = if reversed {
let Some(line) =
ignored(BSplineCurve::create_line_segment(3, ends.end, ends.start))?
else {
return Ok(None);
};
create_rot_srf(k, &Curve::from(line), base_pt, axis, angle_deg)
} else {
create_rot_srf(k, curve, base_pt, axis, angle_deg)
};
let Some(surface) = ignored(made_srf)? else {
return Ok(None);
};
made = Surface::from(surface);
&made
}
};
let expected = ignored(Layout::of(k, reference, base_pt, axis))?;
let actual = ignored(Layout::of(k, &candidate, base_pt, axis))?;
@@ -227,7 +242,8 @@ fn in_half_plane(
Ok(true)
}
/// The analytic surface for the curve's shape, before the layout test.
/// The analytic surface for the curve's shape, before the layout test, and whether it
/// is laid out as the reversed curve's B-spline (a line falling along the axis).
fn candidate(
k: &Kernel,
curve: &Curve,
@@ -236,7 +252,7 @@ fn candidate(
angle_deg: f64,
tol: f64,
axis_tol: f64,
) -> Result<Option<Surface>> {
) -> Result<Option<(Surface, bool)>> {
let ends = curve.ends(k)?;
let (start_dist, _) = line_point_distance(base_pt, axis, ends.start);
let (end_dist, _) = line_point_distance(base_pt, axis, ends.end);
@@ -262,20 +278,25 @@ fn candidate(
if !in_half_plane(k, curve, base_pt, x, y, tol)? {
return Ok(None);
}
// the generator line rises along the axis from the origin; inside out runs
// the NURB from the top, so that v always runs from the curve's start
let (low_h, bot, top, inside_out) = if height > 0.0 {
(start_h, start_dist, end_dist, false)
// the generator line rises along the axis from the origin at the curve's low
// end, never inside out (#1463: tangency-labs/smlib 9853e54's AnalyticSurface;
// an inside-out cylinder's NURB is recognised with its axis reversed by
// IsNurbSurfaceCylinder). A curve falling along the axis is laid out as the
// reversed curve's B-spline (v from its end).
let reversed = height < 0.0;
let (low_h, bot, top) = if reversed {
(end_h, end_dist, start_dist)
} else {
(end_h, end_dist, start_dist, true)
(start_h, start_dist, end_dist)
};
let inside_out = false;
let origin = base_pt + axis * low_h;
let mut cone = if (start_dist - end_dist).abs() <= tol {
Cone::new_cylinder_bounded(
origin,
x,
y,
start_dist,
bot,
0.0,
angle_deg,
height.abs(),
@@ -299,7 +320,7 @@ fn candidate(
)
};
cone.make_nurb(k)?;
return Ok(Some(Surface::from(cone)));
return Ok(Some((Surface::from(cone), reversed)));
}
let Some((frame, radius, arc_start, arc_end)) = curve.is_arc(k, 8, tol)? else {
@@ -374,7 +395,7 @@ fn candidate(
},
)?;
sphere.make_nurb(k)?;
return Ok(Some(Surface::from(sphere)));
return Ok(Some((Surface::from(sphere), false)));
}
// a torus: its minor circle is the arc's, centred off the axis
@@ -412,7 +433,7 @@ fn candidate(
},
)?;
torus.make_nurb(k)?;
Ok(Some(Surface::from(torus)))
Ok(Some((Surface::from(torus), false)))
}
/// What a surface of revolution's natural domain shows of its layout.
@@ -54,10 +54,12 @@
//! [`RotationalSweepGeometry::set_analytic_revolutions`] (off by default, so the
//! default follows `c9c5979`; `PrimitiveCreation`'s partial rotational sweep sets it
//! from `Kernel::fixes.revolve_analytic_profile_faces`), `EdgeSweepHigher` gives a line or circular arc its
//! cylinder, cone, sphere or torus in the B-spline surface's layout instead of
//! cylinder, cone, sphere or torus in the B-spline surface's layout (a line
//! falling along the axis: the reversed line's, its cylinder or cone built from the
//! low end and never inside out, as 9853e54's `AnalyticSurface`, #1463) instead of
//! `CopyAndAddAnalytics` of the B-spline. The trims are the analytic surface's own
//! boundary, in the loop order and orientation the B-spline's give; the normal is
//! still the B-spline's, taken before the copy as upstream.
//! boundary; the normal is the analytic surface's, taken before the copy as the
//! fork does (`AnalyticSurface` replaces `sm_CreateRotSrf`).
//! - **Recursion.** None.
use crate::curve::curve::p8::CurveIntersectOptions;
@@ -873,6 +875,28 @@ impl RotationalSweepGeometry {
};
let mut new_surf = Surface::from(new_surf);
// SolidCraft change (tangency-labs/smlib 0b143f7, 9853e54): the cylinder,
// cone, sphere or torus the curve sweeps, in place of the B-spline before its
// normal is taken (AnalyticSurface precedes it), which CopyAndAddAnalytics
// does not give
let analytic = if self.base.make_analytics && self.analytic_revolutions && !is_plane {
analytic_revolution(
k,
&copied_orig_edge_curve,
self.base_pt,
self.axis,
self.angle,
eps,
Some(&new_surf),
)?
} else {
None
};
let is_analytic = analytic.is_some();
if let Some(surface) = analytic {
new_surf = surface;
}
let start_uv = new_surf.natural_uv_domain_with_nurb(k)?.min();
let normal = new_surf.evaluate_normal(k, start_uv, Side::Left, Side::Left)?;
out.normal = Some(normal);
@@ -891,28 +915,9 @@ impl RotationalSweepGeometry {
}
// Create corresponding analytics if requested
if self.base.make_analytics {
// SolidCraft change (tangency-labs/smlib 0b143f7): the cylinder, cone,
// sphere or torus in the B-spline's layout, which CopyAndAddAnalytics
// does not give
let analytic = if self.analytic_revolutions && !is_plane {
analytic_revolution(
k,
&copied_orig_edge_curve,
self.base_pt,
self.axis,
self.angle,
eps,
Some(&new_surf),
)?
} else {
None
};
new_surf = match analytic {
Some(surface) => surface,
// Copy pNewSurf, when possible as an analytic surface
None => new_surf.copy_and_add_analytics(k)?,
};
if self.base.make_analytics && !is_analytic {
// Copy pNewSurf, when possible as an analytic surface
new_surf = new_surf.copy_and_add_analytics(k)?;
}
// eStartTrimCurveParam fixes which of the umin/vmin line of the surface
@@ -1465,7 +1470,29 @@ mod tests {
n => n,
};
assert_eq!(analytic, expected, "{name}");
// the same loop: roles, orientations, tolerances and the normal
let (n_new, n_old) = (new.normal.unwrap(), old.normal.unwrap());
if matches!(name, "cone" | "drill point") {
// #1463: a line falling along the axis sweeps its cone from the low
// end, never inside out (fork 9853e54), laid out as the reversed
// line's B-spline: the loop runs the other way round, its normal
// the analytic surface's, against the B-spline's
if let SurfaceKind::Cone(c) = surface.kind() {
assert!(!c.inside_out(), "{name}");
}
assert!(
(n_new + n_old).length() < 1e-12,
"{name}: {n_new:?} {n_old:?}"
);
assert_eq!(new.trim_curves.len(), old.trim_curves.len(), "{name}");
assert_eq!(new.face_tol, old.face_tol, "{name}");
continue;
}
// the same loop: roles, orientations, tolerances and the normal (the
// analytic surface's, to rounding)
assert!(
(n_new - n_old).length() < 1e-12,
"{name}: {n_new:?} {n_old:?}"
);
assert_eq!(new.trim_curves.len(), old.trim_curves.len(), "{name}");
assert_eq!(new.uv_trim_curves.len(), old.uv_trim_curves.len(), "{name}");
assert_eq!(new.orients, old.orients, "{name}");
@@ -1484,7 +1511,6 @@ mod tests {
),
"{name}"
);
assert_eq!(new.normal, old.normal, "{name}");
assert_eq!(new.face_tol, old.face_tol, "{name}");
// each 3D trim has the B-spline trim's ends and lies on the new surface
for (i, (c, d)) in new.trim_curves.iter().zip(&old.trim_curves).enumerate() {
@@ -0,0 +1,222 @@
//! Regression tests for tangency-labs/solidcore#1463: a revolve's inside-out cylinder
//! comes back from its NURBS with its axis reversed.
//!
//! Two defects combine (WR-496):
//! - `Cone::is_nurb_surface_cylinder` (`SmCylinder::IsNurbSurfaceCylinder`) keeps the
//! bottom-knot circle and the NURB's linear direction for an inside-out NURB, so the
//! recognised cylinder's Z is reversed and its STEP evaluation is mirrored in height.
//! Upstream does the same; `fixes.nurb_cylinder_inside_out_frame` (opt-in) builds the
//! frame as `is_nurb_surface_cone` does.
//! - With `fixes.revolve_analytic_profile_faces`, the revolve made an inside-out
//! cylinder (or cone) for a profile line running against the axis. Fork commit
//! tangency-labs/smlib 9853e54, which that flag ports, builds them from the low end
//! upwards, never inside out.
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)] // test helpers
use solidcore::curve::curve::Curve;
use solidcore::geom::attribute::{AttributeStore, ObjectKey};
use solidcore::mass::integrator::properties::{brep_volume, PropertyAttributes};
use solidcore::nurbs::b_spline_curve::BSplineCurve;
use solidcore::nurbs::b_spline_surface_n_lib::p2::EvaluateFlags;
use solidcore::surface::cone::Cone;
use solidcore::surface::surface::SurfaceKind;
use solidcore::sweep::api_brep::create_rotational_sweep;
use solidcore::topology::brep::p2::is_manifold_solid;
use solidcore::{Kernel, Point3, Vec3};
/// `SmCone::AssertValid` #3: `((P - foot) x dP/d(sweep)) . Z`, positive for a valid cone.
fn test3(c: &Cone) -> f64 {
let can = c.canonical();
let (o, z) = (can.origin.origin(), can.origin.z_axis());
let base = c.rev().base();
let uv = base.natural_uv_domain().evaluate(0.45678, 0.45678);
let d = base.evaluate(uv, 1, 1, EvaluateFlags::default()).unwrap();
let sweep = if can.swap_uv { d[0][1] } else { d[1][0] };
(d[0][0] - (o + z * (d[0][0] - o).dot(z)))
.cross(sweep)
.dot(z)
}
/// The largest distance between the cone's STEP and NURB evaluations over its domain.
fn step_nurb_gap(k: &Kernel, c: &Cone) -> f64 {
let domain = c.rev().base().natural_uv_domain();
let mut worst: f64 = 0.0;
for (s, t) in [(0.4567, 0.7654), (0.1, 0.9), (0.9, 0.1), (0.5, 0.5)] {
let uv = domain.evaluate(s, t);
let step = c.evaluate_point(k, uv).unwrap();
let nurb = c.rev().base().evaluate_point(uv).unwrap();
worst = worst.max((step - nurb).length());
}
worst
}
/// Radius 8 about +Z, height 22, its NURB's linear direction from the top (inside out).
fn inside_out_cylinder(k: &Kernel) -> Cone {
let mut c = Cone::new_cylinder_bounded(
Point3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 0.0),
Vec3::new(0.0, 1.0, 0.0),
8.0,
0.0,
360.0,
22.0,
false,
true,
false,
);
c.make_nurb(k).unwrap();
c
}
#[test]
fn issue_1463_inside_out_nurb_cylinder_keeps_its_axis() {
let mut k = Kernel::default();
let c = inside_out_cylinder(&k);
let made = test3(&c);
assert!(made > 0.0, "the made cylinder is valid: {made}");
// the fix on: the cylinder it was given
k.fixes.nurb_cylinder_inside_out_frame = true;
let r = Cone::is_nurb_surface_cylinder(&k, c.rev().base(), 1.0)
.unwrap()
.unwrap();
let can = r.canonical();
assert!(r.inside_out());
assert!(
(can.origin.origin() - Vec3::ZERO).length() < 1e-12,
"{:?}",
can.origin.origin()
);
assert!(
(can.origin.z_axis() - Vec3::Z).length() < 1e-12,
"{:?}",
can.origin.z_axis()
);
assert!((r.base_radius() - 8.0).abs() < 1e-12);
assert!(
(test3(&r) - made).abs() < 1e-9 * made,
"{} {made}",
test3(&r)
);
let gap = step_nurb_gap(&k, &r);
assert!(gap < 1e-12, "STEP vs NURB {gap}");
// the fix off: upstream's reversed axis from the bottom-knot circle (z = 22)
k.fixes.nurb_cylinder_inside_out_frame = false;
let r = Cone::is_nurb_surface_cylinder(&k, c.rev().base(), 1.0)
.unwrap()
.unwrap();
let can = r.canonical();
assert!(r.inside_out());
assert!((can.origin.origin() - Vec3::new(0.0, 0.0, 22.0)).length() < 1e-12);
assert!((can.origin.z_axis() + Vec3::Z).length() < 1e-12);
assert!(
(test3(&r) + made).abs() < 1e-9 * made,
"{} {made}",
test3(&r)
);
assert!(step_nurb_gap(&k, &r) > 1.0);
}
fn seg(a: Vec3, b: Vec3) -> Curve {
Curve::from(BSplineCurve::create_line_segment(3, a, b).unwrap())
}
/// The cones of a revolve of the rig's profile (sweeps/726: 8..30 x 0..22 about Z),
/// either winding, turned `angle` degrees, scaled by `scale`; the revolve must be a
/// manifold solid of the profile's swept volume.
fn revolve_cones(k: &mut Kernel, reverse: bool, angle: f64, scale: f64) -> Vec<Cone> {
let p = |x: f64, z: f64| Vec3::new(x * scale, 0.0, z * scale);
let mut pts = [
p(8.0, 0.0),
p(30.0, 0.0),
p(30.0, 22.0),
p(18.0, 22.0),
p(18.0, 10.0),
p(8.0, 10.0),
];
if reverse {
pts.reverse();
}
let curves: Vec<Curve> = (0..pts.len())
.map(|i| seg(pts[i], pts[(i + 1) % pts.len()]))
.collect();
let refs: Vec<Option<&Curve>> = curves.iter().map(Some).collect();
let mut b =
create_rotational_sweep(k, &refs, Vec3::ZERO, Vec3::Z, angle, angle < 360.0).unwrap();
assert!(is_manifold_solid(&b).unwrap(), "{reverse} {angle} {scale}");
// the integral of x over the profile: 10 (30^2 - 8^2) / 2 + 12 (30^2 - 18^2) / 2
let want = angle.to_radians() * 7636.0 * scale.powi(3);
let store = AttributeStore::default();
let attrs = PropertyAttributes {
store: &store,
key_of: &|_| ObjectKey(0),
};
let got = brep_volume(k, &mut b, 1e-6, &attrs).unwrap();
assert!(
(got - want).abs() <= 1e-6 * want,
"{reverse} {angle} {scale}: volume {got} {want}"
);
b.face_list()
.iter()
.filter_map(
|&f| match b.face(f).unwrap().surface.as_ref().unwrap().kind() {
SurfaceKind::Cone(c) => Some((**c).clone()),
_ => None,
},
)
.collect()
}
#[test]
fn issue_1463_revolved_cylinders_are_never_inside_out() {
for (reverse, angle, scale) in [
(false, 360.0, 1.0),
(true, 360.0, 1.0),
(false, 180.0, 1.0),
(true, 180.0, 1.0),
(true, 90.0, 1e-3),
(false, 270.0, 1e3),
] {
let case = format!("reverse {reverse}, {angle} deg, scale {scale}");
let mut k = Kernel::default();
k.fixes.revolve_analytic_profile_faces = true;
let cones = revolve_cones(&mut k, reverse, angle, scale);
assert_eq!(cones.len(), 3, "{case}");
for c in &cones {
assert!(c.is_cylinder_class(), "{case}");
// fork 9853e54: built from the low end upwards along the revolve's axis
assert!(!c.inside_out(), "{case}: an inside-out cylinder");
assert!(
(c.canonical().origin.z_axis() - Vec3::Z).length() < 1e-12,
"{case}"
);
let made = test3(c);
assert!(made > 0.0, "{case}: the made cylinder {made}");
// the C++ reader's round trip, with upstream's IsNurbSurfaceCylinder and
// with the fix: the same cylinder
for fix in [false, true] {
k.fixes.nurb_cylinder_inside_out_frame = fix;
let r = Cone::is_nurb_surface_cylinder(&k, c.rev().base(), 1.0)
.unwrap()
.unwrap();
let gap = step_nurb_gap(&k, &r);
assert!(gap < 1e-12 * (1.0 + 30.0 * scale), "{case} {fix}: {gap}");
assert!(
(test3(&r) - made).abs() <= 1e-9 * made,
"{case} {fix}: {} {made}",
test3(&r)
);
let (a, b) = (r.canonical().origin, c.canonical().origin);
assert!((a.z_axis() - b.z_axis()).length() < 1e-12, "{case} {fix}");
}
k.fixes.nurb_cylinder_inside_out_frame = false;
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
Updates: WR-448
The fork commit is public now: tangency-labs/smlib 9853e54 ("a revolved curve is its
analytic surface, per profile curve", cherry-pick of 0b143f7's change).
Its `SmRotationalSweepGeometry::AnalyticSurface` builds a line's cylinder or cone from
the low end of the line along the axis upwards with `CreateCanonical`. It is never
inside out. The port laid out a line falling along the axis as an inside-out
cylinder or cone, v running from the curve's start. `IsNurbSurfaceCylinder` then
recognised that cylinder's NURB with its axis reversed (WR-496, tangency-labs/solidcore#1463).
With `fixes.revolve_analytic_profile_faces`, `sweep::analytic_revolution` now builds a
falling line's cylinder or cone from its low end, not inside out. It is laid out as
the B-spline surface of revolution of the reversed line, and checked against that
B-spline. `RotationalSweepGeometry::edge_sweep_higher` swaps the analytic surface in
before taking the normal, as the fork does (`AnalyticSurface` replaces
`sm_CreateRotSrf` there). The loop then comes from the analytic surface's boundary,
and the edge-orientation test handles the reversed side.
Spheres and tori keep the port's layout, which still uses `inside_out` for a meridian
that falls. `IsNurbSurfaceSphere` and `IsNurbSurfaceTorus` have no reported defect for
those.
@@ -0,0 +1,10 @@
#### WR-496. `SmCylinder::IsNurbSurfaceCylinder` reverses an inside-out NURB's axis
- **Severity:** Wrong result (a cylinder whose STEP evaluation is mirrored in height against its own NURB: `SmCone::AssertValid` #3 and `SmSurfOfRevolution` #8/#9/#10 fail, and edges miss the surface, `SmBrep` #4 Bad Gap).
- **Area:** Surfaces (cones and cylinders)
- **Location:** `source/SMLib/src/SmCylinder.cpp`, `SmCylinder::IsNurbSurfaceCylinder` (def. `:233`): the Z axis is `centre(top-knot iso) - centre(bottom-knot iso)` and the origin the bottom-knot circle; when the iso circles turn the other way about that Z it sets `bInsideOut = TRUE` ("Do some correction here", `:320`) and keeps the frame.
- **What happens:** an inside-out NURB (its linear direction against the circles' right-hand axis) comes back as a cylinder with Z reversed, from the wrong end. `SmCone::IsNurbSurfaceCone` handles the same NURB correctly: Z against the linear direction, origin at the top-knot circle.
- **Trigger:** tangency-labs/solidcore#1463: `SmCylinder(origin 0, X, Y, r 8, 0..360, h 22, inside out)`, its NURB made and recognised: origin z = 22, Z = -Z, test #3 = -7.861 (the made cylinder's +7.861), STEP vs NURB 11.68. A flat-format round trip of any inside-out cylinder (SolidCraft's `AnalyticOfNurbs`) hits it.
- **How found:** SolidCraft rig cases (sweeps/726, sweeps/4008, sweeps/1866), issue #1463.
- **Confirmed:** by reading the pinned source; the Rust port matches it (difftest `surface.cone_p2` cases `issue1463_cyl_*`, inside-out NURBs with the fix off). The fork (tangency-labs/smlib 5afca53) has the same code.
- **Fix:** for an inside-out NURB, build the frame as `IsNurbSurfaceCone` does (origin the top-knot circle, Z its negation).
- **Rust port:** opt-in `Kernel::fixes.nurb_cylinder_inside_out_frame` (default `false`, upstream's frame) in `Cone::is_nurb_surface_cylinder`, `crates/solidcore/src/surface/cone/p2.rs`. Test `crates/solidcore/tests/issue_1463_revolve_inside_out_cylinder.rs`. Separately, the opt-in `fixes.revolve_analytic_profile_faces` (WR-448) no longer makes inside-out cylinders or cones: like tangency-labs/smlib 9853e54's `SmRotationalSweepGeometry::AnalyticSurface`, a line falling along the axis is built from its low end. Fixes tangency-labs/solidcore#1463.