Repository: celeritas-project/celeritas
Pull request #1783: Create IntersectRegion for a convex extruded polygon
Author: login: elliottbiondo
Labels: name: enhancement
description: New feature or request
name: orange
description: Work on ORANGE geometry engine
Created: 2025-05-13T21:15:40Z | Merged: 2025-05-23T20:04:19Z

This MR creates an `IntersectRegion` for a convex, extruded polygon, with scaling along the z-axis. This is the next step toward supporting Geant4 `xtru` solids, which will consist of unions of these extruded polygons over all z-levels, with non-convex support added by subtracting out concave regions. At first glance it appears that extruded polygons could be used to model Geant4 `GenTrap`s, but this is not the case, as the latter allows for the top and bottom quadrilaterals to have fundamentally different shapes.


Diff:
diff --git a/src/orange/orangeinp/IntersectRegion.cc b/src/orange/orangeinp/IntersectRegion.cc
index 6ddacc23ed..7a2b172e17 100644
--- a/src/orange/orangeinp/IntersectRegion.cc
+++ b/src/orange/orangeinp/IntersectRegion.cc
@@ -23,6 +23,7 @@
 #include "orange/surf/PlaneAligned.hh"
 #include "orange/surf/SimpleQuadric.hh"
 #include "orange/surf/SphereCentered.hh"
+#include "orange/univ/detail/Utils.hh"
 
 #include "IntersectSurfaceBuilder.hh"
 #include "ObjectIO.json.hh"
@@ -527,6 +528,140 @@ void EllipticalCone::output(JsonPimpl* j) const
     to_json_pimpl(j, *this);
 }
 
+//---------------------------------------------------------------------------//
+// ExtrudedPolygon
+//---------------------------------------------------------------------------//
+/*!
+ * Construct from a convex polygon and bottom/top faces.
+ */
+ExtrudedPolygon::ExtrudedPolygon(ExtrudedPolygon::VecReal2 const& polygon,
+                                 ExtrudedPolygon::PolygonFace const& bot_face,
+                                 ExtrudedPolygon::PolygonFace const& top_face)
+    : line_segment_{bot_face.line_segment_point, top_face.line_segment_point}
+    , scaling_factors_{bot_face.scaling_factor, top_face.scaling_factor}
+
+{
+    constexpr auto bot = Bound::lo;
+    constexpr auto top = Bound::hi;
+
+    CELER_VALIDATE(polygon.size() >= 3,
+                   << "polygon must consist of at least 3 points");
+    CELER_VALIDATE(scaling_factors_[bot] > 0 && scaling_factors_[top] > 0,
+                   << "scaling factors must be positive");
+    CELER_VALIDATE(line_segment_[bot][Z] < line_segment_[top][Z],
+                   << "line segment must begin with lower z value");
+
+    // Calculate min/max x/y values, used as both characteristic lengths
+    // generating a floating-point tolerance, and generating surfaces for
+    // bounding box creation
+    x_range_ = this->calc_range(polygon, X);
+    y_range_ = this->calc_range(polygon, Y);
+
+    // Store only non-collinear points
+    Real3 const extents{
+        x_range_[1] - x_range_[0], y_range_[1] - y_range_[0], 0};
+    real_type abs_tol = ::celeritas::detail::BumpCalculator(
+        Tolerance<>::from_default())(extents);
+
+    polygon_ = detail::filter_collinear_points(polygon, abs_tol);
+
+    // After removing collinear points, at least 3 points must remain
+    CELER_VALIDATE(polygon_.size() >= 3,
+                   << "polygon must consist of at least 3 points");
+
+    // After removing collinear points, the polygon should have a *strictly*
+    // clockwise orientation, which also guarantees it is convex.
+    CELER_VALIDATE(
+        has_orientation(make_span(polygon_), detail::Orientation::clockwise),
+        << "polygon must be specified in strictly clockwise order");
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Build surfaces.
+ */
+void ExtrudedPolygon::build(IntersectSurfaceBuilder& insert_surface) const
+{
+    constexpr auto bot = Bound::lo;
+    constexpr auto top = Bound::hi;
+
+    // Insert the upper and lower Z bounding planes
+    insert_surface(Sense::outside, PlaneZ{line_segment_[bot][Z]});
+    insert_surface(Sense::inside, PlaneZ{line_segment_[top][Z]});
+
+    // Insert all vertical bounding planes
+    for (auto i : range(polygon_.size()))
+    {
+        // Current and next point on the polygon
+        auto const& p_a = polygon_[i];
+        auto const& p_b = polygon_[(i + 1) % polygon_.size()];
+
+        // Specify points in an order such that the normal is outward-facing
+        // (via the right-hand rule), given that the polygon is provided in
+        // clockwise order
+        auto p0 = scaling_factors_[bot] * Real3{p_a[X], p_a[Y], 0}
+                  + line_segment_[bot];
+        auto p1 = scaling_factors_[top] * Real3{p_a[X], p_a[Y], 0}
+                  + line_segment_[top];
+        auto p2 = scaling_factors_[bot] * Real3{p_b[X], p_b[Y], 0}
+                  + line_segment_[bot];
+
+        insert_surface(Sense::inside, Plane{p0, p1, p2});
+    }
+
+    // Establish bbox
+    constexpr auto inf = numeric_limits<real_type>::infinity();
+    insert_surface(Sense::inside,
+                   BBox::from_unchecked({x_range_[0], y_range_[0], -inf},
+                                        {x_range_[1], y_range_[1], inf}));
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Write output to the given JSON object.
+ */
+void ExtrudedPolygon::output(JsonPimpl* j) const
+{
+    to_json_pimpl(j, *this);
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Calculate the min/max x or y values of the extruded region.
+ *
+ * Note that these are not simply the extrema of the polygon, but take into
+ * account the translation and scaling of the polygon as it is extruded along
+ * the line segment.
+ */
+auto ExtrudedPolygon::calc_range(VecReal2 const& polygon, size_type dir)
+    -> Range
+{
+    CELER_EXPECT(dir == X || dir == Y);
+
+    constexpr auto bot = Bound::lo;
+    constexpr auto top = Bound::hi;
+
+    Range range;
+
+    // Find min/max x or y values of the polygon itself
+    auto [poly_min_it, poly_max_it] = std::minmax_element(
+        polygon.begin(), polygon.end(), [&dir](auto const& a, auto const& b) {
+            return a[dir] < b[dir];
+        });
+    auto poly_min = (*poly_min_it)[dir];
+    auto poly_max = (*poly_max_it)[dir];
+
+    // Find the extrema taking into account the extrusion process
+    range[0]
+        = std::min(poly_min * scaling_factors_[bot] + line_segment_[bot][dir],
+                   poly_min * scaling_factors_[top] + line_segment_[top][dir]);
+    range[1]
+        = std::max(poly_max * scaling_factors_[bot] + line_segment_[bot][dir],
+                   poly_max * scaling_factors_[top] + line_segment_[top][dir]);
+
+    return range;
+}
+
 //---------------------------------------------------------------------------//
 // GENPRISM
 //---------------------------------------------------------------------------//
diff --git a/src/orange/orangeinp/IntersectRegion.hh b/src/orange/orangeinp/IntersectRegion.hh
index 7c8171ce0b..90fe8d1f8e 100644
--- a/src/orange/orangeinp/IntersectRegion.hh
+++ b/src/orange/orangeinp/IntersectRegion.hh
@@ -9,6 +9,8 @@
 
 #include "corecel/Macros.hh"
 #include "corecel/cont/Array.hh"
+#include "corecel/cont/EnumArray.hh"
+#include "corecel/grid/GridTypes.hh"
 #include "corecel/math/Turn.hh"
 #include "orange/OrangeTypes.hh"
 
@@ -316,6 +318,97 @@ class EllipticalCone final : public IntersectRegionInterface
     real_type hh_;
 };
 
+//---------------------------------------------------------------------------//
+/*!
+ * Region formed by extruding + scaling a convex polygon along a line segment.
+ *
+ * The convex polygon is supplied as a set of points on the XY plane in
+ * clockwise order. The line segment and scaling factors are specified by
+ * providing a line segment point and scaling factor for the top and bottom
+ * polygon faces of the region. The line segment point of the top face must
+ * have a z value greater than that of the bottom face. Along the line segment,
+ * the size of the polygon is linearly scaled in accordance with scaling
+ * factors.
+ *
+ * As is done in Geant4, construction is done by first applying scaling factors
+ * to the upper and lower polygons via scalar multiplication with each polygon
+ * point, then the points on the line are used to offset the upper and lower
+ * polygons.
+ */
+class ExtrudedPolygon final : public IntersectRegionInterface
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using VecReal2 = std::vector<Real2>;
+
+    //! Specifies the top or bottom face of the ExtrudedPolygon
+    struct PolygonFace
+    {
+        //! Start or end point of the line segment the polygon is extruded
+        //! along
+        Real3 line_segment_point{};
+
+        //! The fractional amount this face is scaled
+        real_type scaling_factor{};
+    };
+    //!@}
+
+  public:
+    // Construct from a convex polygon and bottom/top faces
+    ExtrudedPolygon(VecReal2 const& polygon,
+                    PolygonFace const& bot_face,
+                    PolygonFace const& top_face);
+
+    // Build surfaces
+    void build(IntersectSurfaceBuilder&) const final;
+
+    // Output to JSON
+    void output(JsonPimpl*) const final;
+
+    //// ACCESSORS ////
+
+    //! Polygon points (2D)
+    VecReal2 polygon() const { return polygon_; }
+
+    //! Bottom point of the line segment
+    Real3 bot_line_segment_point() const { return line_segment_[Bound::lo]; }
+
+    //! Top point of the line segment
+    Real3 top_line_segment_point() const { return line_segment_[Bound::hi]; }
+
+    //! Bottom scaling factor
+    real_type bot_scaling_factor() const
+    {
+        return scaling_factors_[Bound::lo];
+    }
+
+    //! Top scaling factor
+    real_type top_scaling_factor() const
+    {
+        return scaling_factors_[Bound::hi];
+    }
+
+  private:
+    //// TYPES ////
+    using Range = celeritas::Array<real_type, 2>;
+
+    //// DATA ////
+
+    VecReal2 polygon_;
+
+    EnumArray<Bound, Real3> line_segment_;
+    EnumArray<Bound, real_type> scaling_factors_;
+
+    Range x_range_;
+    Range y_range_;
+
+    // >> HELPER FUNCTIONS
+
+    // Calculate the min/max x or y values of the extruded region
+    Range calc_range(VecReal2 const& polygon, size_type dir);
+};
+
 //---------------------------------------------------------------------------//
 /*!
  * A generalized polygon with parallel flat faces along the *z* axis.
diff --git a/src/orange/orangeinp/ObjectIO.json.cc b/src/orange/orangeinp/ObjectIO.json.cc
index cf643c8e81..6b9f2d35c1 100644
--- a/src/orange/orangeinp/ObjectIO.json.cc
+++ b/src/orange/orangeinp/ObjectIO.json.cc
@@ -197,6 +197,15 @@ void to_json(nlohmann::json& j, EllipticalCone const& cr)
          SIO_ATTR_PAIR(cr, upper_radii),
          SIO_ATTR_PAIR(cr, halfheight)};
 }
+void to_json(nlohmann::json& j, ExtrudedPolygon const& cr)
+{
+    j = {{"_type", "extrudedpolygon"},
+         SIO_ATTR_PAIR(cr, polygon),
+         SIO_ATTR_PAIR(cr, bot_line_segment_point),
+         SIO_ATTR_PAIR(cr, top_line_segment_point),
+         SIO_ATTR_PAIR(cr, bot_scaling_factor),
+         SIO_ATTR_PAIR(cr, top_scaling_factor)};
+}
 void to_json(nlohmann::json& j, GenPrism const& cr)
 {
     j = {{"_type", "genprism"},
diff --git a/src/orange/orangeinp/ObjectIO.json.hh b/src/orange/orangeinp/ObjectIO.json.hh
index aa0708a9cd..a29fa15b45 100644
--- a/src/orange/orangeinp/ObjectIO.json.hh
+++ b/src/orange/orangeinp/ObjectIO.json.hh
@@ -40,6 +40,7 @@ class Cylinder;
 class Ellipsoid;
 class EllipticalCylinder;
 class EllipticalCone;
+class ExtrudedPolygon;
 class GenPrism;
 class InfSlab;
 class InfWedge;
@@ -76,6 +77,7 @@ void to_json(nlohmann::json& j, Cylinder const& cr);
 void to_json(nlohmann::json& j, Ellipsoid const& cr);
 void to_json(nlohmann::json& j, EllipticalCylinder const& cr);
 void to_json(nlohmann::json& j, EllipticalCone const& cr);
+void to_json(nlohmann::json& j, ExtrudedPolygon const& cr);
 void to_json(nlohmann::json& j, GenPrism const& cr);
 void to_json(nlohmann::json& j, InfSlab const& cr);
 void to_json(nlohmann::json& j, InfWedge const& cr);
diff --git a/src/orange/orangeinp/detail/ConvexHullFinder.hh b/src/orange/orangeinp/detail/ConvexHullFinder.hh
index cbc841255d..76082eda1b 100644
--- a/src/orange/orangeinp/detail/ConvexHullFinder.hh
+++ b/src/orange/orangeinp/detail/ConvexHullFinder.hh
@@ -280,7 +280,7 @@ size_type ConvexHullFinder<T>::min_element_idx() const
 /*!
  * Determine if three elements form a clockwise turn using the cross product.
  *
- * Here, colinear points are considered clockwise.
+ * Here, collinear points are considered clockwise.
  */
 template<class T>
 auto ConvexHullFinder<T>::is_clockwise(size_type i_prev,
diff --git a/src/orange/orangeinp/detail/PolygonUtils.hh b/src/orange/orangeinp/detail/PolygonUtils.hh
index 4bc4abc3e8..bfa6aa84ee 100644
--- a/src/orange/orangeinp/detail/PolygonUtils.hh
+++ b/src/orange/orangeinp/detail/PolygonUtils.hh
@@ -35,50 +35,16 @@ enum class Orientation
     counterclockwise = 1,
 };
 
-//---------------------------------------------------------------------------//
-/*!
- * Functor for calculating orientation with a tolerance for collinearity.
- */
-template<class T>
-class SoftOrientation
-{
-  public:
-    using real_type = T;
-    using Real2 = Array<real_type, 2>;
-
-    // Construct with default tolerance
-    CELER_CONSTEXPR_FUNCTION SoftOrientation() : soft_zero_{} {}
-
-    // Construct with specified absolute tolerance
-    explicit CELER_FUNCTION SoftOrientation(real_type abs_tol)
-        : soft_zero_{abs_tol}
-    {
-    }
-
-    // Calculate orientation with tolerance for collinearity
-    CELER_FUNCTION Orientation operator()(Real2 const& a,
-                                          Real2 const& b,
-                                          Real2 const& c) const
-    {
-        auto crossp = (b[0] - a[0]) * (c[1] - b[1])
-                      - (b[1] - a[1]) * (c[0] - b[0]);
-        return soft_zero_(crossp) ? Orientation::collinear
-               : crossp < 0       ? Orientation::clockwise
-                                  : Orientation::counterclockwise;
-    }
-
-  private:
-    SoftZero<real_type> soft_zero_;
-};
-
 //---------------------------------------------------------------------------//
 // FREE FUNCTIONS
 //---------------------------------------------------------------------------//
 /*!
  * Find orientation of ordered vertices in 2D coordinates.
  */
-inline Orientation
-calc_orientation(Real2 const& a, Real2 const& b, Real2 const& c)
+template<class T>
+inline Orientation calc_orientation(celeritas::Array<T, 2> const& a,
+                                    celeritas::Array<T, 2> const& b,
+                                    celeritas::Array<T, 2> const& c)
 {
     auto crossp = (b[0] - a[0]) * (c[1] - b[1]) - (b[1] - a[1]) * (c[0] - b[0]);
     return crossp < 0   ? Orientation::clockwise
@@ -99,7 +65,9 @@ inline bool has_orientation(Span<Real2 const> corners, Orientation o)
     {
         auto j = (i + 1) % corners.size();
         auto k = (i + 2) % corners.size();
-        if (calc_orientation(corners[i], corners[j], corners[k]) != o)
+        if (calc_orientation<Real2::value_type>(
+                corners[i], corners[j], corners[k])
+            != o)
             return false;
     }
     return true;
@@ -119,6 +87,92 @@ is_same_orientation(Orientation a, Orientation b, bool degen_ok = false)
     return (a == b);
 }
 
+//---------------------------------------------------------------------------//
+/*!
+ * Functor for calculating orientation with a tolerance for collinearity.
+ *
+ * Collinearity is based on a supplied absolute tolerance. For three ordered
+ * points a, b, c, point b is collinear if the displacement, d, is less than
+ * the absolute tolerance.
+ * \verbatim
+                b
+               . .
+             .  .  .
+           .    .    .
+         .      . d    .
+       .  t     .        .
+     a . . . . . . . . . . c
+   \endverbatim
+ * The displacement is calculated as follows.
+ *
+ * Let:
+ * \verbatim
+   u = b - a
+   v = c - a
+   \endverbatim
+ *
+ * In 2D, the cross product can be written as,
+ *
+ * \verbatim
+   u x v = |u| |v| sin(t),
+   \endverbatim
+ *
+ * noting that this is a different cross product (different vectors) compared
+ * to the cross product used for orientation determination. Geometrically, the
+ * displacement can be calculated as,
+ *
+ *  \verbatim
+    d = |u| sin(t).
+    \endverbatim
+ *
+ * Therefore,
+ *
+ * \verbatim
+   d = |u| (u x v) / (|u| |v|)
+     = (u x v)/|v|.
+   \endverbatim
+ */
+template<class T>
+class SoftOrientation
+{
+  public:
+    using real_type = T;
+    using Real2 = Array<real_type, 2>;
+
+    // Construct with default tolerance
+    CELER_CONSTEXPR_FUNCTION SoftOrientation() : soft_zero_{} {}
+
+    // Construct with specified absolute tolerance
+    explicit CELER_FUNCTION SoftOrientation(real_type abs_tol)
+        : soft_zero_{abs_tol}
+    {
+    }
+
+    // Calculate orientation with tolerance for collinearity
+    CELER_FUNCTION Orientation operator()(Real2 const& a,
+                                          Real2 const& b,
+                                          Real2 const& c) const
+    {
+        Real2 u{b[0] - a[0], b[1] - a[1]};
+        Real2 v{c[0] - a[0], c[1] - a[1]};
+
+        auto cross_product = (u[0] * v[1] - v[0] * u[1]);
+        auto magnitude = std::hypot(v[0], v[1]);
+
+        if (magnitude == 0 || soft_zero_(cross_product / magnitude))
+        {
+            return Orientation::collinear;
+        }
+        else
+        {
+            return calc_orientation<real_type>(a, b, c);
+        }
+    }
+
+  private:
+    SoftZero<real_type> soft_zero_;
+};
+
 //---------------------------------------------------------------------------//
 /*!
  * Check if a 2D polygon is convex.
@@ -134,7 +188,8 @@ inline bool is_convex(Span<Real2 const> corners, bool degen_ok = false)
     {
         auto j = (i + 1) % corners.size();
         auto k = (i + 2) % corners.size();
-        auto cur = calc_orientation(corners[i], corners[j], corners[k]);
+        auto cur = calc_orientation<Real2::value_type>(
+            corners[i], corners[j], corners[k]);
         if (ref == Orientation::collinear)
         {
             // First non-collinear point
@@ -150,6 +205,55 @@ inline bool is_convex(Span<Real2 const> corners, bool degen_ok = false)
     return true;
 }
 
+//---------------------------------------------------------------------------//
+/*!
+ * Return the non-collinear subset of the supplied corners, as a copy.
+ *
+ * Points are checked for collinearity dynamically, i.e, if a point is found to
+ * be collinear, it is not used for future collinearity checks.
+ */
+inline std::vector<Real2>
+filter_collinear_points(std::vector<Real2> const& corners, double abs_tol)
+{
+    CELER_EXPECT(corners.size() >= 3);
+
+    std::vector<Real2> result;
+    result.reserve(corners.size());
+
+    SoftOrientation<Real2::value_type> soft_ori(abs_tol);
+
+    // Temporarily assume first point is not collinear. This is necessary to
+    // handle the case where all points are locally collinear, but some points
+    // are globally non-collinear, e.g., a many-sided regular polygon.
+    result.push_back(corners[0]);
+
+    for (auto i : range<size_type>(1, corners.size()))
+    {
+        auto j = i + 1 < corners.size() ? i + 1 : 0;
+
+        if (soft_ori(result.back(), corners[i], corners[j])
+            != Orientation::collinear)
+        {
+            result.push_back(corners[i]);
+        }
+    }
+
+    // Make sure there are enough filtered points to specificy a polygon.
+    CELER_ASSERT(result.size() >= 3);
+
+    // If it turns out that the first point is actually collinear, remove it.
+    if (soft_ori(result.back(), result[0], result[1]) == Orientation::collinear)
+    {
+        result.erase(result.begin());
+    }
+
+    // It shouldn't be possible for the potential removal of the first point
+    // to leave us with fewer than 3 points, but check just in case.
+    CELER_ENSURE(result.size() >= 3);
+
+    return result;
+}
+
 //---------------------------------------------------------------------------//
 }  // namespace detail
 }  // namespace orangeinp
diff --git a/src/orange/surf/Plane.hh b/src/orange/surf/Plane.hh
index aa3048446b..d66061f547 100644
--- a/src/orange/surf/Plane.hh
+++ b/src/orange/surf/Plane.hh
@@ -9,6 +9,7 @@
 #include "corecel/Macros.hh"
 #include "corecel/cont/Array.hh"
 #include "corecel/cont/Span.hh"
+#include "corecel/math/ArrayOperators.hh"
 #include "corecel/math/ArrayUtils.hh"
 #include "orange/OrangeTypes.hh"
 #include "orange/SenseUtils.hh"
@@ -56,6 +57,10 @@ class Plane
     // Construct with normal and displacement
     explicit inline CELER_FUNCTION Plane(Real3 const& n, real_type d);
 
+    // Construct from three points
+    explicit inline CELER_FUNCTION
+    Plane(Real3 const& p0, Real3 const& p1, Real3 const& p2);
+
     // Construct from raw data
     template<class R>
     explicit inline CELER_FUNCTION Plane(Span<R, StorageSpan::extent>);
@@ -117,6 +122,36 @@ CELER_FUNCTION Plane::Plane(Real3 const& n, real_type d) : normal_{n}, d_{d}
     CELER_EXPECT(is_soft_unit_vector(normal_));
 }
 
+//---------------------------------------------------------------------------//
+/*!
+ * Construct from three points.
+ *
+ * The direction of the normal is dictated by the right-hand rule, assuming
+ * the normal vector, C, is given by:
+ * \f[
+ * \vec{A} = \vec{p_1} - \vec{p_0},
+ * \vec{B} = \vec{p_2} - \vec{p_0},
+ * \vec{C} = \vec{A} \times  \vec{B}.
+ * \f]
+ * \verbatim
+             ^
+             | C
+             |
+             |
+   p1 _______| p0
+        A   /
+           / B
+          /
+         p2
+   \endverbatim
+ */
+CELER_FUNCTION Plane::Plane(Real3 const& p0, Real3 const& p1, Real3 const& p2)
+    : normal_{make_unit_vector(cross_product(p1 - p0, p2 - p0))}
+    , d_{dot_product(normal_, p0)}
+{
+    CELER_EXPECT(is_soft_unit_vector(normal_));
+}
+
 //---------------------------------------------------------------------------//
 /*!
  * Construct from raw data.
diff --git a/test/orange/orangeinp/IntersectRegion.test.cc b/test/orange/orangeinp/IntersectRegion.test.cc
index 78f9bf19c8..5faea134b0 100644
--- a/test/orange/orangeinp/IntersectRegion.test.cc
+++ b/test/orange/orangeinp/IntersectRegion.test.cc
@@ -563,6 +563,131 @@ TEST_F(EllipticalConeTest, vertex)
     EXPECT_VEC_SOFT_EQ((Real3{2, 4, 4}), result.exterior.upper());
 }
 
+//---------------------------------------------------------------------------//
+// EXTRUDEDPOLYGON
+//---------------------------------------------------------------------------//
+using ExtrudedPolygonTest = IntersectRegionTest;
+
+TEST_F(ExtrudedPolygonTest, simple_cube)
+{
+    // Test a simple unit cube
+    ExtrudedPolygon::VecReal2 polygon{
+        Real2{0, 0}, Real2{0, 1}, Real2{1, 1}, Real2{1, 0}};
+
+    ExtrudedPolygon::PolygonFace bot{Real3{0, 0, 0}, 1};
+    ExtrudedPolygon::PolygonFace top{Real3{0, 0, 1}, 1};
+
+    auto result = this->test(ExtrudedPolygon(polygon, bot, top));
+
+    static char const expected_node[] = "all(+0, -1, +2, -3, -4, +5)";
+    static char const* const expected_surfaces[] = {"Plane: z=0",
+                                                    "Plane: z=1",
+                                                    "Plane: x=0",
+                                                    "Plane: y=1",
+                                                    "Plane: x=1",
+                                                    "Plane: y=0"};
+
+    EXPECT_EQ(expected_node, result.node);
+    EXPECT_VEC_EQ(expected_surfaces, result.surfaces);
+
+    EXPECT_VEC_SOFT_EQ((Real3{0, 0, 0}), result.exterior.lower());
+    EXPECT_VEC_SOFT_EQ((Real3{1, 1, 1}), result.exterior.upper());
+}
+
+TEST_F(ExtrudedPolygonTest, collinear)
+{
+    // Same test as simple_cube, but with collinear points
+    ExtrudedPolygon::VecReal2 polygon{Real2{0, 0},
+                                      Real2{0, 0.5},
+                                      Real2{0, 1},
+                                      Real2{0.5, 1},
+                                      Real2{1, 1},
+                                      Real2{1, 0.5},
+                                      Real2{1, 0},
+                                      Real2{0.7, 0},
+                                      Real2{0.3, 0}};
+
+    ExtrudedPolygon::PolygonFace bot{Real3{0, 0, 0}, 1};
+    ExtrudedPolygon::PolygonFace top{Real3{0, 0, 1}, 1};
+
+    auto result = this->test(ExtrudedPolygon(polygon, bot, top));
+
+    static char const expected_node[] = "all(+0, -1, +2, -3, -4, +5)";
+    static char const* const expected_surfaces[] = {"Plane: z=0",
+                                                    "Plane: z=1",
+                                                    "Plane: x=0",
+                                                    "Plane: y=1",
+                                                    "Plane: x=1",
+                                                    "Plane: y=0"};
+
+    EXPECT_EQ(expected_node, result.node);
+    EXPECT_VEC_EQ(expected_surfaces, result.surfaces);
+
+    EXPECT_VEC_SOFT_EQ((Real3{0, 0, 0}), result.exterior.lower());
+    EXPECT_VEC_SOFT_EQ((Real3{1, 1, 1}), result.exterior.upper());
+}
+
+TEST_F(ExtrudedPolygonTest, flat_top_pyramid)
+{
+    ExtrudedPolygon::VecReal2 polygon{
+        Real2{0, 0}, Real2{0, 1}, Real2{1, 1}, Real2{1, 0}};
+
+    ExtrudedPolygon::PolygonFace bot{Real3{0, 0, 0}, 1};
+    ExtrudedPolygon::PolygonFace top{Real3{0, 0, 0.5}, 0.5};
+
+    auto result = this->test(ExtrudedPolygon(polygon, bot, top));
+
+    static char const expected_node[] = "all(+0, -1, +2, -3, -4, +5)";
+    // Planes have x- and y-slopes equal to +/- sqrt(2)/2, as expected
+    static char const* const expected_surfaces[]
+        = {"Plane: z=0",
+           "Plane: z=0.5",
+           "Plane: x=0",
+           "Plane: n={-0,0.70711,0.70711}, d=0.70711",
+           "Plane: n={0.70711,0,0.70711}, d=0.70711",
+           "Plane: y=0"};
+
+    EXPECT_EQ(expected_node, result.node);
+    EXPECT_VEC_EQ(expected_surfaces, result.surfaces);
+
+    EXPECT_VEC_SOFT_EQ((Real3{0, 0, 0}), result.exterior.lower());
+    EXPECT_VEC_SOFT_EQ((Real3{1, 1, 0.5}), result.exterior.upper());
+}
+
+TEST_F(ExtrudedPolygonTest, skewed)
+{
+    // Irregular hexagon with a single collinear point at (0, 0)
+    ExtrudedPolygon::VecReal2 polygon{Real2{0, 0},
+                                      Real2{-1, 0},
+                                      Real2{-2, 1},
+                                      Real2{-1, 3},
+                                      Real2{1, 4},
+                                      Real2{2, 2},
+                                      Real2{1, 0}};
+
+    ExtrudedPolygon::PolygonFace bot{Real3{4, 3, 10}, 0.7};
+    ExtrudedPolygon::PolygonFace top{Real3{10, 11, 15}, 0.5};
+
+    auto result = this->test(ExtrudedPolygon(polygon, bot, top));
+
+    static char const expected_node[] = "all(+0, -1, +2, -3, +4, -5, +6, +7)";
+    static char const* const expected_surfaces[]
+        = {"Plane: z=10",
+           "Plane: z=15",
+           "Plane: n={0.3152,0.3152,-0.89516}, d=-6.9658",
+           "Plane: n={-0.8165,0.40825,0.40825}, d=3.4701",
+           "Plane: n={0.35448,-0.70895,0.6097}, d=3.6511",
+           "Plane: n={0.45718,0.22859,-0.8595}, d=-5.1204",
+           "Plane: n={-0.85138,0.42569,0.3065}, d=0.34055",
+           "Plane: n={0,0.53,-0.848}, d=-6.89"};
+
+    EXPECT_EQ(expected_node, result.node);
+    EXPECT_VEC_EQ(expected_surfaces, result.surfaces);
+
+    EXPECT_VEC_SOFT_EQ((Real3{2.6, 3, 10}), result.exterior.lower());
+    EXPECT_VEC_SOFT_EQ((Real3{11, 13, 15}), result.exterior.upper());
+}
+
 //---------------------------------------------------------------------------//
 // GENPRISM
 //---------------------------------------------------------------------------//
diff --git a/test/orange/orangeinp/detail/ConvexHullFinder.test.cc b/test/orange/orangeinp/detail/ConvexHullFinder.test.cc
index da3774b677..7714c12d08 100644
--- a/test/orange/orangeinp/detail/ConvexHullFinder.test.cc
+++ b/test/orange/orangeinp/detail/ConvexHullFinder.test.cc
@@ -145,7 +145,7 @@ TEST_F(ConvexHullFinderTest, last_concavity)
 
 //---------------------------------------------------------------------------//
 /*
- * Test case with many colinear points, including the first and last points
+ * Test case with many collinear points, including the first and last points
  * encountered.
  *
  * The starting point is point 7.
@@ -158,7 +158,7 @@ TEST_F(ConvexHullFinderTest, last_concavity)
  *           7
  *
  */
-TEST_F(ConvexHullFinderTest, colinear)
+TEST_F(ConvexHullFinderTest, collinear)
 {
     VecReal2 p{{0, 0},
                {0.5, 0},
diff --git a/test/orange/orangeinp/detail/PolygonUtils.test.cc b/test/orange/orangeinp/detail/PolygonUtils.test.cc
index 7ea9fb9997..78750c40bd 100644
--- a/test/orange/orangeinp/detail/PolygonUtils.test.cc
+++ b/test/orange/orangeinp/detail/PolygonUtils.test.cc
@@ -32,41 +32,29 @@ constexpr auto ccw = Orientation::counterclockwise;
 constexpr auto cw = Orientation::clockwise;
 constexpr auto col = Orientation::collinear;
 
+// Function for comparing filtered polygon results
+auto expect_polygon_equal = [](VecReal2 const& a, VecReal2 const& b) {
+    EXPECT_EQ(a.size(), b.size());
+    for (auto i : range(a.size()))
+    {
+        EXPECT_EQ(a[i][0], b[i][0]);
+        EXPECT_EQ(a[i][1], b[i][1]);
+    }
+};
+
 //---------------------------------------------------------------------------//
 // TESTS
 //---------------------------------------------------------------------------//
 
-TEST(PolygonUtilsTest, soft_orientation)
-{
-    SoftOrientation tight_soft_ori(1e-10);
-    SoftOrientation loose_soft_ori(1e-1);
-
-    // Basic tests
-    EXPECT_EQ(ccw, tight_soft_ori({0, 0}, {4, 4}, {1, 2}));
-    EXPECT_EQ(cw, tight_soft_ori({0, 0}, {4, 4}, {2, 1}));
-    EXPECT_EQ(col, tight_soft_ori({0, 0}, {4, 4}, {2, 2}));
-    EXPECT_EQ(col, tight_soft_ori({0, 0}, {1, 1}, {2, 2}));
-    EXPECT_EQ(col, tight_soft_ori({2, 2}, {1, 1}, {0, 0}));
-    EXPECT_EQ(col, tight_soft_ori({0, 0}, {0, 0}, {1, 1}));
-    EXPECT_EQ(col, tight_soft_ori({0, 0}, {0, 0}, {0, 0}));
-
-    // Collinearity tests
-    EXPECT_EQ(ccw, tight_soft_ori({0, 0}, {1, 1}, {2, 2 + 0.01}));
-    EXPECT_EQ(col, loose_soft_ori({0, 0}, {1, 1}, {2, 2 + 0.01}));
-
-    EXPECT_EQ(cw, tight_soft_ori({0, 0}, {1, 1}, {2, 2 - 0.01}));
-    EXPECT_EQ(col, loose_soft_ori({0, 0}, {1, 1}, {2, 2 - 0.01}));
-}
-
 TEST(PolygonUtilsTest, calc_orientation)
 {
-    EXPECT_EQ(ccw, calc_orientation({0, 0}, {4, 4}, {1, 2}));
-    EXPECT_EQ(cw, calc_orientation({0, 0}, {4, 4}, {2, 1}));
-    EXPECT_EQ(col, calc_orientation({0, 0}, {4, 4}, {2, 2}));
-    EXPECT_EQ(col, calc_orientation({0, 0}, {1, 1}, {2, 2}));
-    EXPECT_EQ(col, calc_orientation({2, 2}, {1, 1}, {0, 0}));
-    EXPECT_EQ(col, calc_orientation({0, 0}, {0, 0}, {1, 1}));
-    EXPECT_EQ(col, calc_orientation({0, 0}, {0, 0}, {0, 0}));
+    EXPECT_EQ(ccw, calc_orientation<real_type>({0, 0}, {4, 4}, {1, 2}));
+    EXPECT_EQ(cw, calc_orientation<real_type>({0, 0}, {4, 4}, {2, 1}));
+    EXPECT_EQ(col, calc_orientation<real_type>({0, 0}, {4, 4}, {2, 2}));
+    EXPECT_EQ(col, calc_orientation<real_type>({0, 0}, {1, 1}, {2, 2}));
+    EXPECT_EQ(col, calc_orientation<real_type>({2, 2}, {1, 1}, {0, 0}));
+    EXPECT_EQ(col, calc_orientation<real_type>({0, 0}, {0, 0}, {1, 1}));
+    EXPECT_EQ(col, calc_orientation<real_type>({0, 0}, {0, 0}, {0, 0}));
 }
 
 TEST(PolygonUtilsTest, has_orientation)
@@ -96,6 +84,31 @@ TEST(PolygonUtilsTest, is_same_orientation)
     EXPECT_TRUE(is_same_orientation(col, cw, degen_ok));
 }
 
+TEST(PolygonUtilsTest, soft_orientation)
+{
+    SoftOrientation tight_soft_ori(1e-10);
+    SoftOrientation loose_soft_ori(0.01);
+
+    // Basic tests
+    EXPECT_EQ(ccw, tight_soft_ori({0, 0}, {4, 4}, {1, 2}));
+    EXPECT_EQ(cw, tight_soft_ori({0, 0}, {4, 4}, {2, 1}));
+    EXPECT_EQ(col, tight_soft_ori({0, 0}, {4, 4}, {2, 2}));
+    EXPECT_EQ(col, tight_soft_ori({0, 0}, {1, 1}, {2, 2}));
+    EXPECT_EQ(col, tight_soft_ori({2, 2}, {1, 1}, {0, 0}));
+    EXPECT_EQ(col, tight_soft_ori({0, 0}, {0, 0}, {1, 1}));
+    EXPECT_EQ(col, tight_soft_ori({0, 0}, {0, 0}, {0, 0}));
+
+    // Collinearity tests
+    EXPECT_EQ(cw, tight_soft_ori({0, 0}, {1, 0.009}, {2, 0}));
+    EXPECT_EQ(ccw, tight_soft_ori({0, 0}, {1, -0.009}, {2, 0}));
+
+    EXPECT_EQ(col, loose_soft_ori({0, 0}, {1, 0.009}, {2, 0}));
+    EXPECT_EQ(col, loose_soft_ori({0, 0}, {1, -0.009}, {2, 0}));
+
+    EXPECT_EQ(cw, loose_soft_ori({0, 0}, {1, 0.011}, {2, 0}));
+    EXPECT_EQ(ccw, loose_soft_ori({0, 0}, {1, -0.011}, {2, 0}));
+}
+
 TEST(PolygonUtilsTest, convex)
 {
     static Real2 const cw_points[] = {{1, 1}, {1, 2}, {2, 2}, {2, 1}};
@@ -149,6 +162,117 @@ TEST(PolygonUtilsTest, convex_self_intersect)
     EXPECT_FALSE(is_convex(self_int2));
 }
 
+/* Test removal of collinear points using points a through g, which when
+ * traversed clockewise form a convex polygon.
+ *
+ *     c . . . . . d  |_ 2
+ *     .              |.
+ *     .              |     .
+ *    .               |         .
+ *    .               |_ 1          e
+ *    .               |           .
+ *   .                |          .
+ *   .                |         .
+ *   b________________a____g__f_________
+ *   |        |       |       |        |
+ *  -1       -0.5     0      0.5       1
+ */
+TEST(PolygonUtilsTest, filter_collinear_points)
+{
+    // Point locations, as labeled above
+    Real2 a = {0, 0};
+    Real2 b = {-1, -1e-5};
+    Real2 c = {-0.9, -0.1};
+    Real2 d = {0.75, 1};
+    Real2 e = {0.75, 0.5};
+    Real2 f = {0.5, 1e-5};
+    Real2 g = {0.35, 1e-6};
+
+    using VecReal2 = std::vector<Real2>;
+    real_type tol = 0.01;
+
+    // No collinear points (b through f)
+    VecReal2 points{b, c, d, e, f};
+    VecReal2 exp = points;
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Point a is collinear, using a through f, and a comes first
+    points = {a, b, c, d, e, f};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes second
+    points = {f, a, b, c, d, e};
+    exp = {f, b, c, d, e};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes third
+    points = {e, f, a, b, c, d};
+    exp = {e, f, b, c, d};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes last
+    points = {b, c, d, e, f, a};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes second
+    points = {f, a, b, c, d, e};
+    exp = {f, b, c, d, e};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Points a and g are collinear, using a through g, and a comes first
+    points = {a, b, c, d, e, f, g};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes second
+    points = {g, a, b, c, d, e, f};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes third
+    points = {f, g, a, b, c, d, e};
+    exp = {f, b, c, d, e};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes second to last
+    points = {c, d, e, f, g, a, b};
+    exp = {c, d, e, f, b};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes last
+    points = {b, c, d, e, f, g, a};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+}
+
+/* Test pathological case consisting of a many-sided regular polygon with every
+ * point soft collinear with its neighbors due to a large tolerance.
+ */
+TEST(PolygonUtilsTest, filter_collinear_points_pathological)
+{
+    // Create a many-sided regular polygon by placing 20 equally spaced points
+    // on a circle of radius = 1, in clockwise order.
+    VecReal2 points(20);
+    real_type step = static_cast<real_type>(2 * constants::pi / points.size());
+
+    for (auto i : range(points.size()))
+    {
+        real_type theta = -step * i;
+        points[i] = {std::cos(theta), std::sin(theta)};
+    }
+
+    // Choose a tolerance such that adjacent points are soft collinear
+    real_type tol = 0.1;
+    SoftOrientation soft_ori(tol);
+    EXPECT_EQ(Orientation::collinear,
+              soft_ori(points[0], points[1], points[2]));
+
+    // Check that filtering provies more than zero points, in this case 7
+    auto filtered_points = filter_collinear_points(points, tol);
+    EXPECT_EQ(7, filtered_points.size());
+}
 //---------------------------------------------------------------------------//
 }  // namespace test
 }  // namespace detail
diff --git a/test/orange/surf/Plane.test.cc b/test/orange/surf/Plane.test.cc
index ab6c641a9d..9c726e1e7a 100644
--- a/test/orange/surf/Plane.test.cc
+++ b/test/orange/surf/Plane.test.cc
@@ -50,6 +50,20 @@ TEST_F(PlaneTest, construction)
 
     Plane py{PlaneY{2.25}};
     EXPECT_VEC_SOFT_EQ((Real3{0, 1, 0}), py.normal());
+
+    // Construct from three points, in this case a plane passing through the
+    // point (1, 2, 3) with slope (1, 1, 1). Specifying the points in clockwise
+    // order gives a negative normal.
+    Plane p2{Real3{2, 1, 3}, Real3{-3, 5, 4}, Real3{4, 7, -5}};
+    EXPECT_SOFT_EQ(-2 * sqrt(3), p2.displacement());
+    real_type dir = sqrt(3) / 3;
+    EXPECT_VEC_SOFT_EQ(Real3({-dir, -dir, -dir}), p2.normal());
+
+    // Specifying the points in counterclockwise order flips the displacement
+    // and normal
+    Plane p3{Real3{2, 1, 3}, Real3{4, 7, -5}, Real3{-3, 5, 4}};
+    EXPECT_SOFT_EQ(2 * sqrt(3), p3.displacement());
+    EXPECT_VEC_SOFT_EQ(Real3({dir, dir, dir}), p3.normal());
 }
 
 TEST_F(PlaneTest, tracking)


Reviews:
user: login: mrguilima
body: A first review.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T16:28:09Z
user: login: elliottbiondo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T16:41:07Z
user: login: elliottbiondo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T17:58:07Z
user: login: sethrj
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-15T23:49:43Z
user: login: sethrj
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-21T11:17:35Z
user: login: elliottbiondo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T15:00:29Z
user: login: elliottbiondo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T23:24:41Z
user: login: mrguilima
body: Thanks for the implementation!
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T04:38:32Z
user: login: mrguilima
body: More LaTeX suggestions.
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T04:50:09Z
user: login: sethrj
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-23T11:49:09Z
user: login: elliottbiondo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T14:18:47Z
user: login: elliottbiondo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T14:24:20Z
user: login: elliottbiondo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T14:25:33Z
user: login: elliottbiondo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T14:25:59Z
user: login: sethrj
body: Sorry for prolonging this review!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-23T14:49:23Z
user: login: elliottbiondo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T15:40:17Z
user: login: elliottbiondo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T15:48:00Z
user: login: mrguilima
body: LGTM, Thanks!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T17:12:48Z
user: login: mrguilima
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T17:16:49Z
user: login: elliottbiondo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T17:35:28Z
user: login: sethrj
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-07-18T10:09:27Z
user: login: sethrj
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-07-18T10:11:36Z
user: login: elliottbiondo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-07-18T22:23:56Z
user: login: elliottbiondo
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-07-18T22:36:08Z

Review comments:
diff_hunk: @@ -527,6 +535,141 @@ void EllipticalCone::output(JsonPimpl* j) const
     to_json_pimpl(j, *this);
 }
 
+//---------------------------------------------------------------------------//
+// ExtrudedPolygon
+//---------------------------------------------------------------------------//
+/*!
+ * Construct from a convex polygon, line segment, and scaling factors.
+ */
+ExtrudedPolygon::ExtrudedPolygon(
+    ExtrudedPolygon::VecReal2 const& polygon,
+    ExtrudedPolygon::ArrayReal3 const& line_segment,
+    ExtrudedPolygon::ArrayReal const& scaling_factors)
+    : polygon_{polygon}
+    , line_segment_{line_segment}
+    , scaling_factors_{scaling_factors}
+{
+    CELER_VALIDATE(polygon_.size() >= 3,
+                   << "polygon must consist of at least 3 vertices");
+    CELER_VALIDATE(scaling_factors[0] > 0 && scaling_factors[1] > 0,
+                   << "scaling factors must be positive");
+    CELER_VALIDATE(line_segment[BOT][Z] < line_segment[TOP][Z],
+                   << "line segment must begin with lower z value");
+
+    // Calculate min/max x/y values, used as both characteristic lengths for
+    // tolerfance considerations, and bounding box surface locations
path: src/orange/orangeinp/IntersectRegion.cc
user: login: mrguilima
body: ```suggestion
    // tolerance considerations, and bounding box surface locations
```
created_at: 2025-05-14T13:48:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -527,6 +535,141 @@ void EllipticalCone::output(JsonPimpl* j) const
     to_json_pimpl(j, *this);
 }
 
+//---------------------------------------------------------------------------//
+// ExtrudedPolygon
+//---------------------------------------------------------------------------//
+/*!
+ * Construct from a convex polygon, line segment, and scaling factors.
+ */
+ExtrudedPolygon::ExtrudedPolygon(
+    ExtrudedPolygon::VecReal2 const& polygon,
+    ExtrudedPolygon::ArrayReal3 const& line_segment,
+    ExtrudedPolygon::ArrayReal const& scaling_factors)
+    : polygon_{polygon}
+    , line_segment_{line_segment}
+    , scaling_factors_{scaling_factors}
+{
+    CELER_VALIDATE(polygon_.size() >= 3,
+                   << "polygon must consist of at least 3 vertices");
+    CELER_VALIDATE(scaling_factors[0] > 0 && scaling_factors[1] > 0,
+                   << "scaling factors must be positive");
+    CELER_VALIDATE(line_segment[BOT][Z] < line_segment[TOP][Z],
+                   << "line segment must begin with lower z value");
+
+    // Calculate min/max x/y values, used as both characteristic lengths for
+    // tolerfance considerations, and bounding box surface locations
+    this->calc_ranges();
+
+    // Check that the polygon is both convex and specified in clockwise order
+    using extent_type = Real3::value_type;
+    Real3 const extents{static_cast<extent_type>(x_range_[1] - x_range_[0]),
+                        static_cast<extent_type>(y_range_[0] - y_range_[1]),
+                        0};
+
+    real_type abs_tol = ::celeritas::detail::BumpCalculator(
+        Tolerance<>::from_default())(extents);
+
+    CELER_VALIDATE(
+        detail::is_soft_convex_clockwise(make_span(polygon_), abs_tol),
+        << "polygon must be convex and specified in clockwise "
+           "order");
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Build surfaces.
+ */
+void ExtrudedPolygon::build(IntersectSurfaceBuilder& insert_surface) const
+{
+    // Insert the upper and lower bounding planes
+    insert_surface(Sense::outside, PlaneZ{line_segment_[BOT][Z]});
+    insert_surface(Sense::inside, PlaneZ{line_segment_[TOP][Z]});
+
+    // Insert all vertical bounding planes
+    for (auto i : range(polygon_.size()))
+    {
+        // Specify points in such an order that guarentees and outward-facing
path: src/orange/orangeinp/IntersectRegion.cc
user: login: mrguilima
body: ```suggestion
        // Specify points in such an order that guarantees an outward-facing
```
created_at: 2025-05-14T14:48:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -527,6 +535,141 @@ void EllipticalCone::output(JsonPimpl* j) const
     to_json_pimpl(j, *this);
 }
 
+//---------------------------------------------------------------------------//
+// ExtrudedPolygon
+//---------------------------------------------------------------------------//
+/*!
+ * Construct from a convex polygon, line segment, and scaling factors.
+ */
+ExtrudedPolygon::ExtrudedPolygon(
+    ExtrudedPolygon::VecReal2 const& polygon,
+    ExtrudedPolygon::ArrayReal3 const& line_segment,
+    ExtrudedPolygon::ArrayReal const& scaling_factors)
+    : polygon_{polygon}
+    , line_segment_{line_segment}
+    , scaling_factors_{scaling_factors}
+{
+    CELER_VALIDATE(polygon_.size() >= 3,
+                   << "polygon must consist of at least 3 vertices");
+    CELER_VALIDATE(scaling_factors[0] > 0 && scaling_factors[1] > 0,
+                   << "scaling factors must be positive");
+    CELER_VALIDATE(line_segment[BOT][Z] < line_segment[TOP][Z],
+                   << "line segment must begin with lower z value");
+
+    // Calculate min/max x/y values, used as both characteristic lengths for
+    // tolerfance considerations, and bounding box surface locations
+    this->calc_ranges();
+
+    // Check that the polygon is both convex and specified in clockwise order
+    using extent_type = Real3::value_type;
+    Real3 const extents{static_cast<extent_type>(x_range_[1] - x_range_[0]),
+                        static_cast<extent_type>(y_range_[0] - y_range_[1]),
+                        0};
+
+    real_type abs_tol = ::celeritas::detail::BumpCalculator(
+        Tolerance<>::from_default())(extents);
+
+    CELER_VALIDATE(
+        detail::is_soft_convex_clockwise(make_span(polygon_), abs_tol),
+        << "polygon must be convex and specified in clockwise "
+           "order");
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Build surfaces.
+ */
+void ExtrudedPolygon::build(IntersectSurfaceBuilder& insert_surface) const
+{
+    // Insert the upper and lower bounding planes
+    insert_surface(Sense::outside, PlaneZ{line_segment_[BOT][Z]});
+    insert_surface(Sense::inside, PlaneZ{line_segment_[TOP][Z]});
+
+    // Insert all vertical bounding planes
+    for (auto i : range(polygon_.size()))
+    {
+        // Specify points in such an order that guarentees and outward-facing
+        // normal via right-hand rule, given that the polygon is given in
+        // clockwise order
+        auto const& p_a = polygon_[i];
+        auto const& p_b = polygon_[(i + 1) % polygon_.size()];
+
+        auto p0 = scaling_factors_[BOT] * Real3{p_a[X], p_a[Y], 0}
+                  + line_segment_[BOT];
+        auto p1 = scaling_factors_[TOP] * Real3{p_a[X], p_a[Y], 0}
+                  + line_segment_[TOP];
+        auto p2 = scaling_factors_[BOT] * Real3{p_b[X], p_b[Y], 0}
+                  + line_segment_[BOT];
+
+        insert_surface(Sense::inside, Plane{p0, p1, p2});
+    }
+
+    // Insert additional planes for bounding boxes
+    insert_surface(Sense::outside, PlaneX{x_range_[0]});
+    insert_surface(Sense::inside, PlaneX{x_range_[1]});
+    insert_surface(Sense::outside, PlaneY{y_range_[0]});
+    insert_surface(Sense::inside, PlaneY{y_range_[1]});
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Write output to the given JSON object.
+ */
+void ExtrudedPolygon::output(JsonPimpl* j) const
+{
+    to_json_pimpl(j, *this);
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Whether this encloses another extruded polygon
+ *
+ * TODO: *extremely* difficult
+ */
+bool ExtrudedPolygon::encloses(ExtrudedPolygon const& other) const
+{
+    CELER_NOT_IMPLEMENTED("ExtrudedPolygon::encloses");
+}
path: src/orange/orangeinp/IntersectRegion.cc
user: login: mrguilima
body: If _this_ is a convex polygon, just check that all points from the _other_ polygon are inside _this_ one.
created_at: 2025-05-14T14:53:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -239,6 +239,74 @@ class EllipticalCylinder final : public IntersectRegionInterface
     real_type hh_;
 };
 
+//---------------------------------------------------------------------------//
+/*!
+ * Region formed by extruding + scaling a convex polygon along a line segment.
+ *
+ * The convex polygon is supplied as a set of points on the XY plane in
+ * clockwise order. The line segment is supplied as a length 2 array of 3D
+ * points denoting the extrusion path travesed by the polygon's origin,
path: src/orange/orangeinp/IntersectRegion.hh
user: login: mrguilima
body: ```suggestion
 * points denoting the extrusion path traversed by the polygon's origin,
```
created_at: 2025-05-14T15:01:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -239,6 +239,74 @@ class EllipticalCylinder final : public IntersectRegionInterface
     real_type hh_;
 };
 
+//---------------------------------------------------------------------------//
+/*!
+ * Region formed by extruding + scaling a convex polygon along a line segment.
+ *
+ * The convex polygon is supplied as a set of points on the XY plane in
+ * clockwise order. The line segment is supplied as a length 2 array of 3D
+ * points denoting the extrusion path travesed by the polygon's origin,
+ * starting at the minimum Z and moving upward.  Along the line, the size of
+ * the polygon is linearly scaled in accordance with scaling factors supplied
+ * for each line point.
+ *
+ * As is done is Geant4, construction is done by first applying scaling factors
+ * to the upper and lower polygons via scalar multiplication with each polygon
+ * point, then the points on the line are used to offset the upper and
+ * lower polygons.
+ */
+class ExtrudedPolygon final : public IntersectRegionInterface
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using VecReal2 = std::vector<Real2>;
+    using ArrayReal3 = std::array<Real3, 2>;
+    using ArrayReal = std::array<real_type, 2>;
path: src/orange/orangeinp/IntersectRegion.hh
user: login: mrguilima
body: Why using std::array here rather than celeritas::Array<>?
In ConvexHullFinder.hh, Real2 is defined as celeritas::Array<real_type, 2>, can't that definition be reused instead of ArrayReal?

created_at: 2025-05-14T15:16:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -117,6 +122,23 @@ CELER_FUNCTION Plane::Plane(Real3 const& n, real_type d) : normal_{n}, d_{d}
     CELER_EXPECT(is_soft_unit_vector(normal_));
 }
 
+//---------------------------------------------------------------------------//
+/*!
+ * Construct from three points.
+ *
+ * The direciton of the normal is dictated by the right-hand rule, assuming
path: src/orange/surf/Plane.hh
user: login: mrguilima
body: ```suggestion
 * The direction of the normal is dictated by the right-hand rule, assuming
```
created_at: 2025-05-14T15:24:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -527,6 +535,141 @@ void EllipticalCone::output(JsonPimpl* j) const
     to_json_pimpl(j, *this);
 }
 
+//---------------------------------------------------------------------------//
+// ExtrudedPolygon
+//---------------------------------------------------------------------------//
+/*!
+ * Construct from a convex polygon, line segment, and scaling factors.
+ */
+ExtrudedPolygon::ExtrudedPolygon(
+    ExtrudedPolygon::VecReal2 const& polygon,
+    ExtrudedPolygon::ArrayReal3 const& line_segment,
+    ExtrudedPolygon::ArrayReal const& scaling_factors)
+    : polygon_{polygon}
+    , line_segment_{line_segment}
+    , scaling_factors_{scaling_factors}
+{
+    CELER_VALIDATE(polygon_.size() >= 3,
+                   << "polygon must consist of at least 3 vertices");
+    CELER_VALIDATE(scaling_factors[0] > 0 && scaling_factors[1] > 0,
+                   << "scaling factors must be positive");
+    CELER_VALIDATE(line_segment[BOT][Z] < line_segment[TOP][Z],
+                   << "line segment must begin with lower z value");
+
+    // Calculate min/max x/y values, used as both characteristic lengths for
+    // tolerfance considerations, and bounding box surface locations
+    this->calc_ranges();
+
+    // Check that the polygon is both convex and specified in clockwise order
+    using extent_type = Real3::value_type;
+    Real3 const extents{static_cast<extent_type>(x_range_[1] - x_range_[0]),
+                        static_cast<extent_type>(y_range_[0] - y_range_[1]),
+                        0};
+
+    real_type abs_tol = ::celeritas::detail::BumpCalculator(
+        Tolerance<>::from_default())(extents);
+
+    CELER_VALIDATE(
+        detail::is_soft_convex_clockwise(make_span(polygon_), abs_tol),
+        << "polygon must be convex and specified in clockwise "
+           "order");
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Build surfaces.
+ */
+void ExtrudedPolygon::build(IntersectSurfaceBuilder& insert_surface) const
+{
+    // Insert the upper and lower bounding planes
+    insert_surface(Sense::outside, PlaneZ{line_segment_[BOT][Z]});
+    insert_surface(Sense::inside, PlaneZ{line_segment_[TOP][Z]});
+
+    // Insert all vertical bounding planes
+    for (auto i : range(polygon_.size()))
+    {
+        // Specify points in such an order that guarentees and outward-facing
+        // normal via right-hand rule, given that the polygon is given in
+        // clockwise order
+        auto const& p_a = polygon_[i];
+        auto const& p_b = polygon_[(i + 1) % polygon_.size()];
+
+        auto p0 = scaling_factors_[BOT] * Real3{p_a[X], p_a[Y], 0}
+                  + line_segment_[BOT];
+        auto p1 = scaling_factors_[TOP] * Real3{p_a[X], p_a[Y], 0}
+                  + line_segment_[TOP];
+        auto p2 = scaling_factors_[BOT] * Real3{p_b[X], p_b[Y], 0}
+                  + line_segment_[BOT];
+
+        insert_surface(Sense::inside, Plane{p0, p1, p2});
+    }
+
+    // Insert additional planes for bounding boxes
+    insert_surface(Sense::outside, PlaneX{x_range_[0]});
+    insert_surface(Sense::inside, PlaneX{x_range_[1]});
+    insert_surface(Sense::outside, PlaneY{y_range_[0]});
+    insert_surface(Sense::inside, PlaneY{y_range_[1]});
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Write output to the given JSON object.
+ */
+void ExtrudedPolygon::output(JsonPimpl* j) const
+{
+    to_json_pimpl(j, *this);
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Whether this encloses another extruded polygon
+ *
+ * TODO: *extremely* difficult
+ */
+bool ExtrudedPolygon::encloses(ExtrudedPolygon const& other) const
+{
+    CELER_NOT_IMPLEMENTED("ExtrudedPolygon::encloses");
+}
path: src/orange/orangeinp/IntersectRegion.cc
user: login: elliottbiondo
body: Yeah sorry, originally this was going to be for an arbitrary polygon, but with convexity it becomes pretty easy
created_at: 2025-05-14T16:41:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -527,6 +535,141 @@ void EllipticalCone::output(JsonPimpl* j) const
     to_json_pimpl(j, *this);
 }
 
+//---------------------------------------------------------------------------//
+// ExtrudedPolygon
+//---------------------------------------------------------------------------//
+/*!
+ * Construct from a convex polygon, line segment, and scaling factors.
+ */
+ExtrudedPolygon::ExtrudedPolygon(
+    ExtrudedPolygon::VecReal2 const& polygon,
+    ExtrudedPolygon::ArrayReal3 const& line_segment,
+    ExtrudedPolygon::ArrayReal const& scaling_factors)
+    : polygon_{polygon}
+    , line_segment_{line_segment}
+    , scaling_factors_{scaling_factors}
+{
+    CELER_VALIDATE(polygon_.size() >= 3,
+                   << "polygon must consist of at least 3 vertices");
+    CELER_VALIDATE(scaling_factors[0] > 0 && scaling_factors[1] > 0,
+                   << "scaling factors must be positive");
+    CELER_VALIDATE(line_segment[BOT][Z] < line_segment[TOP][Z],
+                   << "line segment must begin with lower z value");
+
+    // Calculate min/max x/y values, used as both characteristic lengths for
+    // tolerfance considerations, and bounding box surface locations
+    this->calc_ranges();
+
+    // Check that the polygon is both convex and specified in clockwise order
+    using extent_type = Real3::value_type;
+    Real3 const extents{static_cast<extent_type>(x_range_[1] - x_range_[0]),
+                        static_cast<extent_type>(y_range_[0] - y_range_[1]),
+                        0};
+
+    real_type abs_tol = ::celeritas::detail::BumpCalculator(
+        Tolerance<>::from_default())(extents);
+
+    CELER_VALIDATE(
+        detail::is_soft_convex_clockwise(make_span(polygon_), abs_tol),
+        << "polygon must be convex and specified in clockwise "
+           "order");
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Build surfaces.
+ */
+void ExtrudedPolygon::build(IntersectSurfaceBuilder& insert_surface) const
+{
+    // Insert the upper and lower bounding planes
+    insert_surface(Sense::outside, PlaneZ{line_segment_[BOT][Z]});
+    insert_surface(Sense::inside, PlaneZ{line_segment_[TOP][Z]});
+
+    // Insert all vertical bounding planes
+    for (auto i : range(polygon_.size()))
+    {
+        // Specify points in such an order that guarentees and outward-facing
+        // normal via right-hand rule, given that the polygon is given in
+        // clockwise order
+        auto const& p_a = polygon_[i];
+        auto const& p_b = polygon_[(i + 1) % polygon_.size()];
+
+        auto p0 = scaling_factors_[BOT] * Real3{p_a[X], p_a[Y], 0}
+                  + line_segment_[BOT];
+        auto p1 = scaling_factors_[TOP] * Real3{p_a[X], p_a[Y], 0}
+                  + line_segment_[TOP];
+        auto p2 = scaling_factors_[BOT] * Real3{p_b[X], p_b[Y], 0}
+                  + line_segment_[BOT];
+
+        insert_surface(Sense::inside, Plane{p0, p1, p2});
+    }
+
+    // Insert additional planes for bounding boxes
+    insert_surface(Sense::outside, PlaneX{x_range_[0]});
+    insert_surface(Sense::inside, PlaneX{x_range_[1]});
+    insert_surface(Sense::outside, PlaneY{y_range_[0]});
+    insert_surface(Sense::inside, PlaneY{y_range_[1]});
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Write output to the given JSON object.
+ */
+void ExtrudedPolygon::output(JsonPimpl* j) const
+{
+    to_json_pimpl(j, *this);
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Whether this encloses another extruded polygon
+ *
+ * TODO: *extremely* difficult
+ */
+bool ExtrudedPolygon::encloses(ExtrudedPolygon const& other) const
+{
+    CELER_NOT_IMPLEMENTED("ExtrudedPolygon::encloses");
+}
path: src/orange/orangeinp/IntersectRegion.cc
user: login: elliottbiondo
body: It's easy, but you do have to actually build the solid first, which feels like the wrong thing to do here. Since `encloses` is just part of the template interface and is currently only used when creating excluded interior regions---which are not applicable for `xtru`---I am going to leave this unimplemented.
created_at: 2025-05-14T17:58:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -527,6 +528,143 @@ void EllipticalCone::output(JsonPimpl* j) const
     to_json_pimpl(j, *this);
 }
 
+//---------------------------------------------------------------------------//
+// ExtrudedPolygon
+//---------------------------------------------------------------------------//
+/*!
+ * Construct from a convex polygon, line segment, and scaling factors.
+ */
+ExtrudedPolygon::ExtrudedPolygon(
+    ExtrudedPolygon::VecReal2 const& polygon,
+    ExtrudedPolygon::ArrayReal3 const& line_segment,
+    ExtrudedPolygon::ArrayReal const& scaling_factors)
+    : line_segment_{line_segment}, scaling_factors_{scaling_factors}
+{
+    CELER_VALIDATE(polygon.size() >= 3,
+                   << "polygon must consist of at least 3 points");
+    CELER_VALIDATE(scaling_factors_[0] > 0 && scaling_factors_[1] > 0,
+                   << "scaling factors must be positive");
+    CELER_VALIDATE(line_segment_[BOT][Z] < line_segment_[TOP][Z],
+                   << "line segment must begin with lower z value");
+
+    // Calculate min/max x/y values, used as both characteristic lengths
+    // generating a floating-point tolerance, and generating surfaces for
+    // bounding box creation
+    this->calc_ranges(polygon);
+
+    // Store only non-colinear points
+    Real3 const extents{
+        x_range_[1] - x_range_[0], y_range_[1] - y_range_[0], 0};
+    real_type abs_tol = ::celeritas::detail::BumpCalculator(
+        Tolerance<>::from_default())(extents);
+
+    auto colinear_mask
+        = detail::make_colinear_mask(make_span(polygon), abs_tol);
+    CELER_ASSERT(colinear_mask.size() == polygon.size());
+    for (auto i : range(polygon.size()))
+    {
+        if (!colinear_mask[i])
+        {
+            polygon_.push_back(polygon[i]);
+        }
+    }
path: src/orange/orangeinp/IntersectRegion.cc
user: login: sethrj
body: I think an [erase remove idiom](https://en.wikipedia.org/wiki/Erase–remove_idiom) might be better than a two-pass filter. Supposing that your tolerance were somewhat loose and you made a circle of points where each triplet is collinear, the current implementation would delete all the points. An algorithm that updates the list continuously as it's processing would still keep a rough circle shape (and as a bonus will avoid the extra vector creation and a second pass).
created_at: 2025-05-15T20:44:08Z
author_association: MEMBER
diff_hunk: @@ -239,6 +239,82 @@ class EllipticalCylinder final : public IntersectRegionInterface
     real_type hh_;
 };
 
+//---------------------------------------------------------------------------//
+/*!
+ * Region formed by extruding + scaling a convex polygon along a line segment.
+ *
+ * The convex polygon is supplied as a set of points on the XY plane in
+ * clockwise order. The line segment is supplied as a length 2 array of 3D
+ * points denoting the extrusion path traversed by the polygon's origin,
+ * starting at the minimum Z and moving upward. Along the line, the size of the
+ * polygon is linearly scaled in accordance with scaling factors supplied for
+ * each line segment point.
+ *
+ * As is done in Geant4, construction is done by first applying scaling factors
+ * to the upper and lower polygons via scalar multiplication with each polygon
+ * point, then the points on the line are used to offset the upper and lower
+ * polygons.
+ *
+ * The line_segment and scaling parameters are stored as const references and
+ * must have lifetimes longer than this class.
+ */
+class ExtrudedPolygon final : public IntersectRegionInterface
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using VecReal2 = std::vector<Real2>;
+    using ArrayReal3 = celeritas::Array<Real3, 2>;
+    using ArrayReal = celeritas::Array<real_type, 2>;
+    //!@}
+
+  public:
+    // Construct from a convex polygon, line segment, and scaling factors
+    ExtrudedPolygon(VecReal2 const& polygon,
+                    ArrayReal3 const& line_segment,
+                    ArrayReal const& scaling);
+
+    // Build surfaces
+    void build(IntersectSurfaceBuilder&) const final;
+
+    // Output to JSON
+    void output(JsonPimpl*) const final;
+
+    //// ACCESSORS ////
+
+    //! Polygon points (2D)
+    VecReal2 polygon() const { return polygon_; }
+
+    //! Line segment points (3D)
+    ArrayReal3 line_segment() const { return line_segment_; }
+
+    //! Scaling factors along the line segment
+    ArrayReal scaling_factors() const { return scaling_factors_; }
+
+  private:
+    //// TYPES ////
+
+    // Enum for differenting between the bottom and top instance of the polygon
+    enum
+    {
+        BOT = 0,
+        TOP = 1
+    };
+
+    //// DATA ////
+
+    VecReal2 polygon_;
+    ArrayReal3 const& line_segment_;
+    ArrayReal const& scaling_factors_;
path: src/orange/orangeinp/IntersectRegion.hh
user: login: sethrj
body: Definitely don't use references here! The solids *always* have a lifetime larger than their construction argument.
created_at: 2025-05-15T20:56:45Z
author_association: MEMBER
diff_hunk: @@ -239,6 +239,82 @@ class EllipticalCylinder final : public IntersectRegionInterface
     real_type hh_;
 };
 
+//---------------------------------------------------------------------------//
+/*!
+ * Region formed by extruding + scaling a convex polygon along a line segment.
+ *
+ * The convex polygon is supplied as a set of points on the XY plane in
+ * clockwise order. The line segment is supplied as a length 2 array of 3D
+ * points denoting the extrusion path traversed by the polygon's origin,
+ * starting at the minimum Z and moving upward. Along the line, the size of the
+ * polygon is linearly scaled in accordance with scaling factors supplied for
+ * each line segment point.
+ *
+ * As is done in Geant4, construction is done by first applying scaling factors
+ * to the upper and lower polygons via scalar multiplication with each polygon
+ * point, then the points on the line are used to offset the upper and lower
+ * polygons.
+ *
+ * The line_segment and scaling parameters are stored as const references and
+ * must have lifetimes longer than this class.
+ */
+class ExtrudedPolygon final : public IntersectRegionInterface
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using VecReal2 = std::vector<Real2>;
+    using ArrayReal3 = celeritas::Array<Real3, 2>;
+    using ArrayReal = celeritas::Array<real_type, 2>;
+    //!@}
+
+  public:
+    // Construct from a convex polygon, line segment, and scaling factors
+    ExtrudedPolygon(VecReal2 const& polygon,
+                    ArrayReal3 const& line_segment,
+                    ArrayReal const& scaling);
+
+    // Build surfaces
+    void build(IntersectSurfaceBuilder&) const final;
+
+    // Output to JSON
+    void output(JsonPimpl*) const final;
+
+    //// ACCESSORS ////
+
+    //! Polygon points (2D)
+    VecReal2 polygon() const { return polygon_; }
+
+    //! Line segment points (3D)
+    ArrayReal3 line_segment() const { return line_segment_; }
+
+    //! Scaling factors along the line segment
+    ArrayReal scaling_factors() const { return scaling_factors_; }
+
+  private:
+    //// TYPES ////
+
+    // Enum for differenting between the bottom and top instance of the polygon
+    enum
+    {
+        BOT = 0,
+        TOP = 1
+    };
path: src/orange/orangeinp/IntersectRegion.hh
user: login: sethrj
body: Avoid all-caps symbol names. Use the existing `enum class Bound` in corecel/grid/GridTypes.hh (an odd place for it, we could move that...) and as a shorthand you can locally use `constexpr auto bot = Bound::lo`. (And you would define `Real3Bound = EnumArray<Bound, Real3>` and `RealBound = EnumArray<Bound, real_type>` to store the data under the hood.
created_at: 2025-05-15T20:57:37Z
author_association: MEMBER
diff_hunk: @@ -239,6 +239,82 @@ class EllipticalCylinder final : public IntersectRegionInterface
     real_type hh_;
 };
 
+//---------------------------------------------------------------------------//
+/*!
+ * Region formed by extruding + scaling a convex polygon along a line segment.
+ *
+ * The convex polygon is supplied as a set of points on the XY plane in
+ * clockwise order. The line segment is supplied as a length 2 array of 3D
+ * points denoting the extrusion path traversed by the polygon's origin,
+ * starting at the minimum Z and moving upward. Along the line, the size of the
+ * polygon is linearly scaled in accordance with scaling factors supplied for
+ * each line segment point.
+ *
+ * As is done in Geant4, construction is done by first applying scaling factors
+ * to the upper and lower polygons via scalar multiplication with each polygon
+ * point, then the points on the line are used to offset the upper and lower
+ * polygons.
+ *
+ * The line_segment and scaling parameters are stored as const references and
+ * must have lifetimes longer than this class.
path: src/orange/orangeinp/IntersectRegion.hh
user: login: sethrj
body: Not gonna happen 😅 besides, they're just a few doubles so it's not harmful to copy.
created_at: 2025-05-15T20:58:23Z
author_association: MEMBER
diff_hunk: @@ -527,6 +528,143 @@ void EllipticalCone::output(JsonPimpl* j) const
     to_json_pimpl(j, *this);
 }
 
+//---------------------------------------------------------------------------//
+// ExtrudedPolygon
+//---------------------------------------------------------------------------//
+/*!
+ * Construct from a convex polygon, line segment, and scaling factors.
+ */
+ExtrudedPolygon::ExtrudedPolygon(
+    ExtrudedPolygon::VecReal2 const& polygon,
+    ExtrudedPolygon::ArrayReal3 const& line_segment,
+    ExtrudedPolygon::ArrayReal const& scaling_factors)
+    : line_segment_{line_segment}, scaling_factors_{scaling_factors}
+{
+    CELER_VALIDATE(polygon.size() >= 3,
+                   << "polygon must consist of at least 3 points");
+    CELER_VALIDATE(scaling_factors_[0] > 0 && scaling_factors_[1] > 0,
+                   << "scaling factors must be positive");
+    CELER_VALIDATE(line_segment_[BOT][Z] < line_segment_[TOP][Z],
+                   << "line segment must begin with lower z value");
+
+    // Calculate min/max x/y values, used as both characteristic lengths
+    // generating a floating-point tolerance, and generating surfaces for
+    // bounding box creation
+    this->calc_ranges(polygon);
+
+    // Store only non-colinear points
+    Real3 const extents{
+        x_range_[1] - x_range_[0], y_range_[1] - y_range_[0], 0};
+    real_type abs_tol = ::celeritas::detail::BumpCalculator(
+        Tolerance<>::from_default())(extents);
+
+    auto colinear_mask
+        = detail::make_colinear_mask(make_span(polygon), abs_tol);
+    CELER_ASSERT(colinear_mask.size() == polygon.size());
+    for (auto i : range(polygon.size()))
+    {
+        if (!colinear_mask[i])
+        {
+            polygon_.push_back(polygon[i]);
+        }
+    }
+
+    // After removing colinear points, at least 3 points must remain
+    CELER_VALIDATE(polygon_.size() >= 3,
+                   << "polygon must consist of at least 3 points");
+
+    // After removing colinear points, the polygon should have a strictly
+    // clockwise orientation, which also guarantees it is convex.
+    CELER_VALIDATE(
+        has_orientation(make_span(polygon_), detail::Orientation::clockwise),
+        << "polygon must be specified in strictly clockwise order");
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Build surfaces.
+ */
+void ExtrudedPolygon::build(IntersectSurfaceBuilder& insert_surface) const
+{
+    // Insert the upper and lower Z bounding planes
+    insert_surface(Sense::outside, PlaneZ{line_segment_[BOT][Z]});
+    insert_surface(Sense::inside, PlaneZ{line_segment_[TOP][Z]});
+
+    // Insert all vertical bounding planes
+    for (auto i : range(polygon_.size()))
+    {
+        // Current and next point on the polygon
+        auto const& p_a = polygon_[i];
+        auto const& p_b = polygon_[(i + 1) % polygon_.size()];
+
+        // Specify points in an order such that the normal is outward-facing
+        // (via the right-hand rule), given that the polygon is provided in
+        // clockwise order
+        auto p0 = scaling_factors_[BOT] * Real3{p_a[X], p_a[Y], 0}
+                  + line_segment_[BOT];
+        auto p1 = scaling_factors_[TOP] * Real3{p_a[X], p_a[Y], 0}
+                  + line_segment_[TOP];
+        auto p2 = scaling_factors_[BOT] * Real3{p_b[X], p_b[Y], 0}
+                  + line_segment_[BOT];
+
+        insert_surface(Sense::inside, Plane{p0, p1, p2});
+    }
+
+    // Insert additional planes for bounding box creation
+    insert_surface(Sense::outside, PlaneX{x_range_[0]});
+    insert_surface(Sense::inside, PlaneX{x_range_[1]});
+    insert_surface(Sense::outside, PlaneY{y_range_[0]});
+    insert_surface(Sense::inside, PlaneY{y_range_[1]});
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Write output to the given JSON object.
+ */
+void ExtrudedPolygon::output(JsonPimpl* j) const
+{
+    to_json_pimpl(j, *this);
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Calculate the min/max x/y values of the extruded IntersectRegion.
+ *
+ * Note that these are not simply the extrema of the polygon, but take into
+ * account the translation and scaling of the polygon as it is extruded along
+ * the line segment.
+ */
+void ExtrudedPolygon::calc_ranges(VecReal2 const& polygon)
+{
+    // Find min/max x/y values of the polygon itself
+    auto [poly_min_x_it, poly_max_x_it] = std::minmax_element(
+        polygon.begin(), polygon.end(), [](auto const& a, auto const& b) {
+            return a[0] < b[0];
+        });
+    auto [poly_min_y_it, poly_max_y_it] = std::minmax_element(
+        polygon.begin(), polygon.end(), [](auto const& a, auto const& b) {
+            return a[1] < b[1];
+        });
+    auto poly_min_x = (*poly_min_x_it)[X];
+    auto poly_max_x = (*poly_max_x_it)[X];
+    auto poly_min_y = (*poly_min_y_it)[Y];
+    auto poly_max_y = (*poly_max_y_it)[Y];
+
+    // Find the extrema taking into account the extrusion process
+    x_range_[0]
+        = std::min(poly_min_x * scaling_factors_[BOT] + line_segment_[BOT][X],
+                   poly_min_x * scaling_factors_[TOP] + line_segment_[TOP][X]);
+    x_range_[1]
+        = std::max(poly_max_x * scaling_factors_[BOT] + line_segment_[BOT][X],
+                   poly_max_x * scaling_factors_[TOP] + line_segment_[TOP][X]);
+    y_range_[0]
+        = std::min(poly_min_y * scaling_factors_[BOT] + line_segment_[BOT][Y],
+                   poly_min_y * scaling_factors_[TOP] + line_segment_[TOP][Y]);
+    y_range_[1]
+        = std::max(poly_max_y * scaling_factors_[BOT] + line_segment_[BOT][Y],
+                   poly_max_y * scaling_factors_[TOP] + line_segment_[TOP][Y]);
+}
path: src/orange/orangeinp/IntersectRegion.cc
user: login: sethrj
body: Make this a const function that returns `T_range` for T in  {X, Y} (given as an argument) and just call it twice in the constructor:
```
x_range_ = calc_ranges(polygon, X);
y_range_ = calc_ranges(polygon, Y);
```
created_at: 2025-05-15T21:01:22Z
author_association: MEMBER
diff_hunk: @@ -527,6 +528,143 @@ void EllipticalCone::output(JsonPimpl* j) const
     to_json_pimpl(j, *this);
 }
 
+//---------------------------------------------------------------------------//
+// ExtrudedPolygon
+//---------------------------------------------------------------------------//
+/*!
+ * Construct from a convex polygon, line segment, and scaling factors.
+ */
+ExtrudedPolygon::ExtrudedPolygon(
+    ExtrudedPolygon::VecReal2 const& polygon,
+    ExtrudedPolygon::ArrayReal3 const& line_segment,
+    ExtrudedPolygon::ArrayReal const& scaling_factors)
+    : line_segment_{line_segment}, scaling_factors_{scaling_factors}
+{
+    CELER_VALIDATE(polygon.size() >= 3,
+                   << "polygon must consist of at least 3 points");
+    CELER_VALIDATE(scaling_factors_[0] > 0 && scaling_factors_[1] > 0,
+                   << "scaling factors must be positive");
+    CELER_VALIDATE(line_segment_[BOT][Z] < line_segment_[TOP][Z],
+                   << "line segment must begin with lower z value");
+
+    // Calculate min/max x/y values, used as both characteristic lengths
+    // generating a floating-point tolerance, and generating surfaces for
+    // bounding box creation
+    this->calc_ranges(polygon);
+
+    // Store only non-colinear points
+    Real3 const extents{
+        x_range_[1] - x_range_[0], y_range_[1] - y_range_[0], 0};
+    real_type abs_tol = ::celeritas::detail::BumpCalculator(
+        Tolerance<>::from_default())(extents);
+
+    auto colinear_mask
+        = detail::make_colinear_mask(make_span(polygon), abs_tol);
+    CELER_ASSERT(colinear_mask.size() == polygon.size());
+    for (auto i : range(polygon.size()))
+    {
+        if (!colinear_mask[i])
+        {
+            polygon_.push_back(polygon[i]);
+        }
+    }
+
+    // After removing colinear points, at least 3 points must remain
+    CELER_VALIDATE(polygon_.size() >= 3,
+                   << "polygon must consist of at least 3 points");
+
+    // After removing colinear points, the polygon should have a strictly
+    // clockwise orientation, which also guarantees it is convex.
+    CELER_VALIDATE(
+        has_orientation(make_span(polygon_), detail::Orientation::clockwise),
+        << "polygon must be specified in strictly clockwise order");
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Build surfaces.
+ */
+void ExtrudedPolygon::build(IntersectSurfaceBuilder& insert_surface) const
+{
+    // Insert the upper and lower Z bounding planes
+    insert_surface(Sense::outside, PlaneZ{line_segment_[BOT][Z]});
+    insert_surface(Sense::inside, PlaneZ{line_segment_[TOP][Z]});
+
+    // Insert all vertical bounding planes
+    for (auto i : range(polygon_.size()))
+    {
+        // Current and next point on the polygon
+        auto const& p_a = polygon_[i];
+        auto const& p_b = polygon_[(i + 1) % polygon_.size()];
+
+        // Specify points in an order such that the normal is outward-facing
+        // (via the right-hand rule), given that the polygon is provided in
+        // clockwise order
+        auto p0 = scaling_factors_[BOT] * Real3{p_a[X], p_a[Y], 0}
+                  + line_segment_[BOT];
+        auto p1 = scaling_factors_[TOP] * Real3{p_a[X], p_a[Y], 0}
+                  + line_segment_[TOP];
+        auto p2 = scaling_factors_[BOT] * Real3{p_b[X], p_b[Y], 0}
+                  + line_segment_[BOT];
+
+        insert_surface(Sense::inside, Plane{p0, p1, p2});
+    }
+
+    // Insert additional planes for bounding box creation
+    insert_surface(Sense::outside, PlaneX{x_range_[0]});
+    insert_surface(Sense::inside, PlaneX{x_range_[1]});
+    insert_surface(Sense::outside, PlaneY{y_range_[0]});
+    insert_surface(Sense::inside, PlaneY{y_range_[1]});
path: src/orange/orangeinp/IntersectRegion.cc
user: login: sethrj
body: Don't add unused surfaces just for the bbox; the `insert_surface(Sense, BBox)` form should be used (which just adjusts the bbox, not creates any surfaces):
```
    constexpr auto inf = numeric_limits<real_type>::infinity();
    insert_surface(Sense::inside,
                   BBox::from_unchecked({x_range_[0], y_range_[0], -inf}, {x_range_[1], y_range_[1], inf});
```
created_at: 2025-05-15T21:05:07Z
author_association: MEMBER
diff_hunk: @@ -239,6 +239,82 @@ class EllipticalCylinder final : public IntersectRegionInterface
     real_type hh_;
 };
 
+//---------------------------------------------------------------------------//
+/*!
+ * Region formed by extruding + scaling a convex polygon along a line segment.
+ *
+ * The convex polygon is supplied as a set of points on the XY plane in
+ * clockwise order. The line segment is supplied as a length 2 array of 3D
+ * points denoting the extrusion path traversed by the polygon's origin,
+ * starting at the minimum Z and moving upward. Along the line, the size of the
+ * polygon is linearly scaled in accordance with scaling factors supplied for
+ * each line segment point.
+ *
+ * As is done in Geant4, construction is done by first applying scaling factors
+ * to the upper and lower polygons via scalar multiplication with each polygon
+ * point, then the points on the line are used to offset the upper and lower
+ * polygons.
+ *
+ * The line_segment and scaling parameters are stored as const references and
+ * must have lifetimes longer than this class.
+ */
+class ExtrudedPolygon final : public IntersectRegionInterface
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using VecReal2 = std::vector<Real2>;
+    using ArrayReal3 = celeritas::Array<Real3, 2>;
+    using ArrayReal = celeritas::Array<real_type, 2>;
+    //!@}
+
+  public:
+    // Construct from a convex polygon, line segment, and scaling factors
+    ExtrudedPolygon(VecReal2 const& polygon,
+                    ArrayReal3 const& line_segment,
+                    ArrayReal const& scaling);
path: src/orange/orangeinp/IntersectRegion.hh
user: login: sethrj
body: The `ArrayReal3` and `ArrayReal` definition is not obvious from the names: the second is really a Real2 but it's a lo/hi rather than x/y. I think maybe use an approach like `GenPrism`: combine segment + scaling into a struct, and pass two of those to the constructor: `lo` and `hi`. Then your internal data structures can use `EnumArray<Bound,  real_type>` etc.
created_at: 2025-05-15T23:49:00Z
author_association: MEMBER
diff_hunk: @@ -149,6 +149,28 @@ TEST(PolygonUtilsTest, convex_self_intersect)
     EXPECT_FALSE(is_convex(self_int2));
 }
 
+TEST(PolygonUtilsTest, filter_colinear_points)
+{
+    auto expect_eq = [](VecReal2 const& a, VecReal2 const& b) {
+        EXPECT_EQ(a.size(), b.size());
+        for (auto i : range(a.size()))
+        {
+            EXPECT_EQ(a[i][0], b[i][0]);
+            EXPECT_EQ(a[i][1], b[i][1]);
+        }
+    };
+
+    using VecReal2 = std::vector<Real2>;
+    real_type tol = 0.01;
+
+    // Nothing colinear
+    VecReal2 corners{{0, 0}, {0, 1}, {1, 1}, {1, 0}};
+    expect_eq(corners, make_colinear_mask(make_span(corners), tol));
+
+    // Nothing colinear
+    VecReal2 corners{{0, 0}, {0, 1}, {1, 1}, {1, 0}};
+    expect_eq(corners, make_colinear_mask(make_span(corners), tol));
+}
path: test/orange/orangeinp/detail/PolygonUtils.test.cc
user: login: sethrj
body: An interesting test to add would be to have a very wide tolerance and generate 1000 points on a circle and see what they filter down to.
created_at: 2025-05-21T11:17:35Z
author_association: MEMBER
diff_hunk: @@ -149,6 +149,28 @@ TEST(PolygonUtilsTest, convex_self_intersect)
     EXPECT_FALSE(is_convex(self_int2));
 }
 
+TEST(PolygonUtilsTest, filter_colinear_points)
+{
+    auto expect_eq = [](VecReal2 const& a, VecReal2 const& b) {
+        EXPECT_EQ(a.size(), b.size());
+        for (auto i : range(a.size()))
+        {
+            EXPECT_EQ(a[i][0], b[i][0]);
+            EXPECT_EQ(a[i][1], b[i][1]);
+        }
+    };
+
+    using VecReal2 = std::vector<Real2>;
+    real_type tol = 0.01;
+
+    // Nothing colinear
+    VecReal2 corners{{0, 0}, {0, 1}, {1, 1}, {1, 0}};
+    expect_eq(corners, make_colinear_mask(make_span(corners), tol));
+
+    // Nothing colinear
+    VecReal2 corners{{0, 0}, {0, 1}, {1, 1}, {1, 0}};
+    expect_eq(corners, make_colinear_mask(make_span(corners), tol));
+}
path: test/orange/orangeinp/detail/PolygonUtils.test.cc
user: login: elliottbiondo
body: Yep I rewrote the code yesterday to handle this case. Will add.
created_at: 2025-05-21T15:00:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -239,6 +239,82 @@ class EllipticalCylinder final : public IntersectRegionInterface
     real_type hh_;
 };
 
+//---------------------------------------------------------------------------//
+/*!
+ * Region formed by extruding + scaling a convex polygon along a line segment.
+ *
+ * The convex polygon is supplied as a set of points on the XY plane in
+ * clockwise order. The line segment is supplied as a length 2 array of 3D
+ * points denoting the extrusion path traversed by the polygon's origin,
+ * starting at the minimum Z and moving upward. Along the line, the size of the
+ * polygon is linearly scaled in accordance with scaling factors supplied for
+ * each line segment point.
+ *
+ * As is done in Geant4, construction is done by first applying scaling factors
+ * to the upper and lower polygons via scalar multiplication with each polygon
+ * point, then the points on the line are used to offset the upper and lower
+ * polygons.
+ *
+ * The line_segment and scaling parameters are stored as const references and
+ * must have lifetimes longer than this class.
+ */
+class ExtrudedPolygon final : public IntersectRegionInterface
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using VecReal2 = std::vector<Real2>;
+    using ArrayReal3 = celeritas::Array<Real3, 2>;
+    using ArrayReal = celeritas::Array<real_type, 2>;
+    //!@}
+
+  public:
+    // Construct from a convex polygon, line segment, and scaling factors
+    ExtrudedPolygon(VecReal2 const& polygon,
+                    ArrayReal3 const& line_segment,
+                    ArrayReal const& scaling);
path: src/orange/orangeinp/IntersectRegion.hh
user: login: elliottbiondo
body: Sure --- but then what should I do for the accessors? If we only store EnumArrays (which I see the value in to catch indexing mistakes) then the accessors would have to reconstruct the `PolyFaces`, which seems messy. Returning an EnumArray seems bad too.
created_at: 2025-05-21T23:24:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -150,6 +192,55 @@ inline bool is_convex(Span<Real2 const> corners, bool degen_ok = false)
     return true;
 }
 
+//---------------------------------------------------------------------------//
+/*!
+ * Return a mask that is true if a point is colinear with its 2 neighbors.
+ *
+ * Points are checked for colinearly dynamically, i.e, if a point is
path: src/orange/orangeinp/detail/PolygonUtils.hh
user: login: mrguilima
body: ```suggestion
 * Points are checked for colinearity dynamically, i.e, if a point is
```
created_at: 2025-05-23T02:28:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -150,6 +192,55 @@ inline bool is_convex(Span<Real2 const> corners, bool degen_ok = false)
     return true;
 }
 
+//---------------------------------------------------------------------------//
+/*!
+ * Return a mask that is true if a point is colinear with its 2 neighbors.
+ *
+ * Points are checked for colinearly dynamically, i.e, if a point is
+ * found to be colinear, it is not used for future colinearity checks.
+ */
+std::vector<Real2>
+filter_collinear_points(std::vector<Real2> const& corners, double abs_tol)
path: src/orange/orangeinp/detail/PolygonUtils.hh
user: login: mrguilima
body: Both colinear and collinear (single or double L) are correct, but one should stick to only one of them.  I prefer colinear (no double L) myself...
created_at: 2025-05-23T02:29:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -527,6 +528,140 @@ void EllipticalCone::output(JsonPimpl* j) const
     to_json_pimpl(j, *this);
 }
 
+//---------------------------------------------------------------------------//
+// ExtrudedPolygon
+//---------------------------------------------------------------------------//
+/*!
+ * Construct from a convex polygon, line segment, and scaling factors.
+ */
+ExtrudedPolygon::ExtrudedPolygon(ExtrudedPolygon::VecReal2 const& polygon,
+                                 ExtrudedPolygon::PolygonFace bot_face,
+                                 ExtrudedPolygon::PolygonFace top_face)
path: src/orange/orangeinp/IntersectRegion.cc
user: login: mrguilima
body: ```suggestion
                                 ExtrudedPolygon::PolygonFace const& bot_face,
                                 ExtrudedPolygon::PolygonFace const& top_face)
```
created_at: 2025-05-23T02:42:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -527,6 +528,140 @@ void EllipticalCone::output(JsonPimpl* j) const
     to_json_pimpl(j, *this);
 }
 
+//---------------------------------------------------------------------------//
+// ExtrudedPolygon
+//---------------------------------------------------------------------------//
+/*!
+ * Construct from a convex polygon, line segment, and scaling factors.
+ */
+ExtrudedPolygon::ExtrudedPolygon(ExtrudedPolygon::VecReal2 const& polygon,
+                                 ExtrudedPolygon::PolygonFace bot_face,
+                                 ExtrudedPolygon::PolygonFace top_face)
+    : line_segment_{bot_face.line_segment_point, top_face.line_segment_point}
+    , scaling_factors_{bot_face.scaling_factor, top_face.scaling_factor}
+
+{
+    constexpr auto bot = Bound::lo;
+    constexpr auto top = Bound::hi;
+
+    CELER_VALIDATE(polygon.size() >= 3,
+                   << "polygon must consist of at least 3 points");
+    CELER_VALIDATE(scaling_factors_[bot] > 0 && scaling_factors_[top] > 0,
+                   << "scaling factors must be positive");
+    CELER_VALIDATE(line_segment_[bot][Z] < line_segment_[top][Z],
+                   << "line segment must begin with lower z value");
+
+    // Calculate min/max x/y values, used as both characteristic lengths
+    // generating a floating-point tolerance, and generating surfaces for
+    // bounding box creation
+    x_range_ = this->calc_range(polygon, X);
+    y_range_ = this->calc_range(polygon, Y);
+
+    // Store only non-colinear points
+    Real3 const extents{
+        x_range_[1] - x_range_[0], y_range_[1] - y_range_[0], 0};
+    real_type abs_tol = ::celeritas::detail::BumpCalculator(
+        Tolerance<>::from_default())(extents);
+
+    polygon_ = detail::filter_collinear_points(polygon, abs_tol);
+
+    // After removing colinear points, at least 3 points must remain
+    CELER_VALIDATE(polygon_.size() >= 3,
+                   << "polygon must consist of at least 3 points");
+
+    // After removing colinear points, the polygon should have a strictly
+    // clockwise orientation, which also guarantees it is convex.
+    CELER_VALIDATE(
+        has_orientation(make_span(polygon_), detail::Orientation::clockwise),
+        << "polygon must be specified in strictly clockwise order");
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Build surfaces.
+ */
+void ExtrudedPolygon::build(IntersectSurfaceBuilder& insert_surface) const
+{
+    constexpr auto bot = Bound::lo;
+    constexpr auto top = Bound::hi;
+
+    // Insert the upper and lower Z bounding planes
+    insert_surface(Sense::outside, PlaneZ{line_segment_[bot][Z]});
+    insert_surface(Sense::inside, PlaneZ{line_segment_[top][Z]});
+
+    // Insert all vertical bounding planes
+    for (auto i : range(polygon_.size()))
+    {
+        // Current and next point on the polygon
+        auto const& p_a = polygon_[i];
+        auto const& p_b = polygon_[(i + 1) % polygon_.size()];
+
+        // Specify points in an order such that the normal is outward-facing
+        // (via the right-hand rule), given that the polygon is provided in
+        // clockwise order
+        auto p0 = scaling_factors_[bot] * Real3{p_a[X], p_a[Y], 0}
+                  + line_segment_[bot];
+        auto p1 = scaling_factors_[top] * Real3{p_a[X], p_a[Y], 0}
+                  + line_segment_[top];
+        auto p2 = scaling_factors_[bot] * Real3{p_b[X], p_b[Y], 0}
+                  + line_segment_[bot];
+
+        insert_surface(Sense::inside, Plane{p0, p1, p2});
+    }
+
+    // Establish bbox
+    constexpr auto inf = numeric_limits<real_type>::infinity();
+    insert_surface(Sense::inside,
+                   BBox::from_unchecked({x_range_[0], y_range_[0], -inf},
+                                        {x_range_[1], y_range_[1], inf}));
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Write output to the given JSON object.
+ */
+void ExtrudedPolygon::output(JsonPimpl* j) const
+{
+    to_json_pimpl(j, *this);
+}
+
+//---------------------------------------------------------------------------//
+/*!
+ * Calculate the min/max x or y values of the extruded IntersectRegion
+ *
+ * Note that these are not simply the extrema of the polygon, but take into
+ * account the translation and scaling of the polygon as it is extruded along
+ * the line segment.
+ */
+auto ExtrudedPolygon::calc_range(VecReal2 const& polygon, size_type dir)
+    -> Range
+{
+    CELER_EXPECT(dir == X || dir == Y);
+
+    constexpr auto bot = Bound::lo;
+    constexpr auto top = Bound::hi;
+
+    Range range;
+
+    // Find min/max x/y values of the polygon itself
+    auto [poly_min_x_it, poly_max_x_it] = std::minmax_element(
path: src/orange/orangeinp/IntersectRegion.cc
user: login: mrguilima
body: Since x/y is given by dir, probably should drop the _x_ in variable names like poly_min_x_it and others.

created_at: 2025-05-23T03:59:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -197,6 +197,14 @@ void to_json(nlohmann::json& j, EllipticalCone const& cr)
          SIO_ATTR_PAIR(cr, upper_radii),
          SIO_ATTR_PAIR(cr, halfheight)};
 }
+void to_json(nlohmann::json& j, ExtrudedPolygon const& cr)
+{
+    j = {
+        {"_type", "extrudedpolygon"}, SIO_ATTR_PAIR(cr, polygon),
+        // SIO_ATTR_PAIR(cr, line_segment),
+        // SIO_ATTR_PAIR(cr, scaling_factors)
path: src/orange/orangeinp/ObjectIO.json.cc
user: login: mrguilima
body: Are these supposed to be commendted out?
created_at: 2025-05-23T04:03:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -119,6 +83,84 @@ is_same_orientation(Orientation a, Orientation b, bool degen_ok = false)
     return (a == b);
 }
 
+//---------------------------------------------------------------------------//
+/*!
+ * Functor for calculating orientation with a tolerance for collinearity.
+ *
+ * Collinearity is based on a supplied absolute tolerance. For three ordered
+ * points a, b, c, point b is collinear if the displacement, d, is less than
+ * the absolute tolerance.
+ *
+ *              b
+ *             . .
+ *           .  .  .
+ *         .    .    .
+ *       .      . d    .
+ *     .  t     .        .
+ *   a . . . . . . . . . . c
+ *
+ * The displacement is calculated as follows.
+ *
+ * Let:
+ * u = b - a
+ * v = c - a
+ *
+ * In 2D, the cross product is can be written as,
path: src/orange/orangeinp/detail/PolygonUtils.hh
user: login: mrguilima
body: ```suggestion
 * In 2D, the cross product can be written as,
```
created_at: 2025-05-23T04:05:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -119,6 +83,84 @@ is_same_orientation(Orientation a, Orientation b, bool degen_ok = false)
     return (a == b);
 }
 
+//---------------------------------------------------------------------------//
+/*!
+ * Functor for calculating orientation with a tolerance for collinearity.
+ *
+ * Collinearity is based on a supplied absolute tolerance. For three ordered
+ * points a, b, c, point b is collinear if the displacement, d, is less than
+ * the absolute tolerance.
+ *
+ *              b
+ *             . .
+ *           .  .  .
+ *         .    .    .
+ *       .      . d    .
+ *     .  t     .        .
+ *   a . . . . . . . . . . c
+ *
+ * The displacement is calculated as follows.
+ *
+ * Let:
+ * u = b - a
+ * v = c - a
+ *
+ * In 2D, the cross product is can be written as,
+ *
+ * u x v = |u| |v| sin(t),
path: src/orange/orangeinp/detail/PolygonUtils.hh
user: login: mrguilima
body: LaTeX-like formatting should be used for math equations in comments:
```suggestion
 * \f[
 *  \vec u \times \vec v = |\vec u| |\vec v| \sin(t),
 * \f]
```
See src/celeritas/field/MagFieldEquation.hh for examples.
created_at: 2025-05-23T04:13:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -117,6 +122,33 @@ CELER_FUNCTION Plane::Plane(Real3 const& n, real_type d) : normal_{n}, d_{d}
     CELER_EXPECT(is_soft_unit_vector(normal_));
 }
 
+//---------------------------------------------------------------------------//
+/*!
+ * Construct from three points.
+ *
+ * The direction of the normal is dictated by the right-hand rule, assuming
+ * the normal vector, C, is given by:
+ * A = (p1 - p0),
+ * B = (p2 - p0),
+ * C = A x B.
path: src/orange/surf/Plane.hh
user: login: mrguilima
body: ```suggestion
 * \f[
 * \vec A = (\vec{p_1} - \vec{p_0}) ,
 * \vec B = (\vec{p_2} - \vec{p_0}) ,
 * \vec C = \vec A \times \vec B .
 * \f]
```
created_at: 2025-05-23T04:24:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -149,6 +162,118 @@ TEST(PolygonUtilsTest, convex_self_intersect)
     EXPECT_FALSE(is_convex(self_int2));
 }
 
+/* Test removal of collinear points using points a through g, which when
+ * traversed clockewise form a convex polygon.
+ *
+ *     c . . . . . d  |_ 2
+ *     .              |.
+ *     .              |     .
+ *    .               |         .
+ *    .               |_ 1          e
+ *    .               |           .
+ *   .                |          .
+ *   .                |         .
+ *   b________________a____g__f_________
+ *   |        |       |       |        |
+ *  -1       -0.5     0      0.5       1
+ */
+TEST(PolygonUtilsTest, filter_collinear_points)
+{
+    // Points locations, as labled above
+    Real2 a = {0, 0};
+    Real2 b = {-1, -1e-5};
+    Real2 c = {-0.9, -0.1};
+    Real2 d = {0.75, 1};
+    Real2 e = {0.75, 0.5};
+    Real2 f = {0.5, 1e-5};
+    Real2 g = {0.35, 1e-6};
+
+    using VecReal2 = std::vector<Real2>;
+    real_type tol = 0.01;
+
+    // no collinear points (b through f)
+    VecReal2 points{b, c, d, e, f};
+    VecReal2 exp = points;
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Point a is collinear, using a through f, and a comes first
+    points = {a, b, c, d, e, f};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is second
+    points = {f, a, b, c, d, e};
+    exp = {f, b, c, d, e};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is third
+    points = {e, f, a, b, c, d};
+    exp = {e, f, b, c, d};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is last
+    points = {b, c, d, e, f, a};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is second
+    points = {f, a, b, c, d, e};
+    exp = {f, b, c, d, e};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Points a and g are collinear, using a through g, and a comes first
+    points = {a, b, c, d, e, f, g};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes second
+    points = {g, a, b, c, d, e, f};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes third
+    points = {f, g, a, b, c, d, e};
+    exp = {f, b, c, d, e};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes second to last
+    points = {c, d, e, f, g, a, b};
+    exp = {c, d, e, f, b};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes last
+    points = {b, c, d, e, f, g, a};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+}
+
+/* Test pathological case consisting of a many-sided regular polygon with every
+ * point locally soft collinear with its neighbors due to a large tolerance.
+ */
+TEST(PolygonUtilsTest, filter_collinear_points_pathological)
+{
+    // Create a many-sided regular polygon by placing 20 equally spaced points
+    // on a circule of radius = 1, in clockwise order.
path: test/orange/orangeinp/detail/PolygonUtils.test.cc
user: login: mrguilima
body: ```suggestion
    // on a circle of radius = 1, in clockwise order.
```
created_at: 2025-05-23T04:26:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -149,6 +162,118 @@ TEST(PolygonUtilsTest, convex_self_intersect)
     EXPECT_FALSE(is_convex(self_int2));
 }
 
+/* Test removal of collinear points using points a through g, which when
+ * traversed clockewise form a convex polygon.
+ *
+ *     c . . . . . d  |_ 2
+ *     .              |.
+ *     .              |     .
+ *    .               |         .
+ *    .               |_ 1          e
+ *    .               |           .
+ *   .                |          .
+ *   .                |         .
+ *   b________________a____g__f_________
+ *   |        |       |       |        |
+ *  -1       -0.5     0      0.5       1
+ */
+TEST(PolygonUtilsTest, filter_collinear_points)
+{
+    // Points locations, as labled above
+    Real2 a = {0, 0};
+    Real2 b = {-1, -1e-5};
+    Real2 c = {-0.9, -0.1};
+    Real2 d = {0.75, 1};
+    Real2 e = {0.75, 0.5};
+    Real2 f = {0.5, 1e-5};
+    Real2 g = {0.35, 1e-6};
+
+    using VecReal2 = std::vector<Real2>;
+    real_type tol = 0.01;
+
+    // no collinear points (b through f)
+    VecReal2 points{b, c, d, e, f};
+    VecReal2 exp = points;
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Point a is collinear, using a through f, and a comes first
+    points = {a, b, c, d, e, f};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is second
+    points = {f, a, b, c, d, e};
+    exp = {f, b, c, d, e};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is third
+    points = {e, f, a, b, c, d};
+    exp = {e, f, b, c, d};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is last
+    points = {b, c, d, e, f, a};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is second
+    points = {f, a, b, c, d, e};
+    exp = {f, b, c, d, e};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Points a and g are collinear, using a through g, and a comes first
+    points = {a, b, c, d, e, f, g};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes second
+    points = {g, a, b, c, d, e, f};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes third
+    points = {f, g, a, b, c, d, e};
+    exp = {f, b, c, d, e};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes second to last
+    points = {c, d, e, f, g, a, b};
+    exp = {c, d, e, f, b};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes last
+    points = {b, c, d, e, f, g, a};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+}
+
+/* Test pathological case consisting of a many-sided regular polygon with every
+ * point locally soft collinear with its neighbors due to a large tolerance.
+ */
+TEST(PolygonUtilsTest, filter_collinear_points_pathological)
+{
+    // Create a many-sided regular polygon by placing 20 equally spaced points
+    // on a circule of radius = 1, in clockwise order.
+    VecReal2 points(20);
+    real_type step = static_cast<real_type>(2 * constants::pi / points.size());
+
+    for (auto i : range(points.size()))
+    {
+        real_type theta = -step * i;
+        points[i] = {std::cos(theta), std::sin(theta)};
+    }
+
+    // Verify choose a tolerance such that the points are locally soft
+    // collinear
+    real_type tol = 0.1;
+    SoftOrientation soft_ori(tol);
+    EXPECT_EQ(Orientation::collinear,
+              soft_ori(points[0], points[1], points[2]));
+
+    // Check that filtering gives us more that zero points, in this case 7
path: test/orange/orangeinp/detail/PolygonUtils.test.cc
user: login: mrguilima
body: ```suggestion
    // Check that filtering gives us more than zero points, in this case 7
```
created_at: 2025-05-23T04:28:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -149,6 +162,118 @@ TEST(PolygonUtilsTest, convex_self_intersect)
     EXPECT_FALSE(is_convex(self_int2));
 }
 
+/* Test removal of collinear points using points a through g, which when
+ * traversed clockewise form a convex polygon.
+ *
+ *     c . . . . . d  |_ 2
+ *     .              |.
+ *     .              |     .
+ *    .               |         .
+ *    .               |_ 1          e
+ *    .               |           .
+ *   .                |          .
+ *   .                |         .
+ *   b________________a____g__f_________
+ *   |        |       |       |        |
+ *  -1       -0.5     0      0.5       1
+ */
+TEST(PolygonUtilsTest, filter_collinear_points)
+{
+    // Points locations, as labled above
+    Real2 a = {0, 0};
+    Real2 b = {-1, -1e-5};
+    Real2 c = {-0.9, -0.1};
+    Real2 d = {0.75, 1};
+    Real2 e = {0.75, 0.5};
+    Real2 f = {0.5, 1e-5};
+    Real2 g = {0.35, 1e-6};
+
+    using VecReal2 = std::vector<Real2>;
+    real_type tol = 0.01;
+
+    // no collinear points (b through f)
+    VecReal2 points{b, c, d, e, f};
+    VecReal2 exp = points;
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Point a is collinear, using a through f, and a comes first
+    points = {a, b, c, d, e, f};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is second
+    points = {f, a, b, c, d, e};
+    exp = {f, b, c, d, e};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is third
+    points = {e, f, a, b, c, d};
+    exp = {e, f, b, c, d};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is last
+    points = {b, c, d, e, f, a};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is second
+    points = {f, a, b, c, d, e};
+    exp = {f, b, c, d, e};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Points a and g are collinear, using a through g, and a comes first
+    points = {a, b, c, d, e, f, g};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes second
+    points = {g, a, b, c, d, e, f};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes third
+    points = {f, g, a, b, c, d, e};
+    exp = {f, b, c, d, e};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes second to last
+    points = {c, d, e, f, g, a, b};
+    exp = {c, d, e, f, b};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes last
+    points = {b, c, d, e, f, g, a};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+}
+
+/* Test pathological case consisting of a many-sided regular polygon with every
+ * point locally soft collinear with its neighbors due to a large tolerance.
+ */
+TEST(PolygonUtilsTest, filter_collinear_points_pathological)
+{
+    // Create a many-sided regular polygon by placing 20 equally spaced points
+    // on a circule of radius = 1, in clockwise order.
+    VecReal2 points(20);
+    real_type step = static_cast<real_type>(2 * constants::pi / points.size());
+
+    for (auto i : range(points.size()))
+    {
+        real_type theta = -step * i;
+        points[i] = {std::cos(theta), std::sin(theta)};
+    }
+
+    // Verify choose a tolerance such that the points are locally soft
path: test/orange/orangeinp/detail/PolygonUtils.test.cc
user: login: mrguilima
body: ```suggestion
    // Choose a tolerance such that the points are locally soft
```
created_at: 2025-05-23T04:28:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -149,6 +162,118 @@ TEST(PolygonUtilsTest, convex_self_intersect)
     EXPECT_FALSE(is_convex(self_int2));
 }
 
+/* Test removal of collinear points using points a through g, which when
+ * traversed clockewise form a convex polygon.
+ *
+ *     c . . . . . d  |_ 2
+ *     .              |.
+ *     .              |     .
+ *    .               |         .
+ *    .               |_ 1          e
+ *    .               |           .
+ *   .                |          .
+ *   .                |         .
+ *   b________________a____g__f_________
+ *   |        |       |       |        |
+ *  -1       -0.5     0      0.5       1
+ */
+TEST(PolygonUtilsTest, filter_collinear_points)
+{
+    // Points locations, as labled above
+    Real2 a = {0, 0};
+    Real2 b = {-1, -1e-5};
+    Real2 c = {-0.9, -0.1};
+    Real2 d = {0.75, 1};
+    Real2 e = {0.75, 0.5};
+    Real2 f = {0.5, 1e-5};
+    Real2 g = {0.35, 1e-6};
+
+    using VecReal2 = std::vector<Real2>;
+    real_type tol = 0.01;
+
+    // no collinear points (b through f)
+    VecReal2 points{b, c, d, e, f};
+    VecReal2 exp = points;
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Point a is collinear, using a through f, and a comes first
+    points = {a, b, c, d, e, f};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is second
+    points = {f, a, b, c, d, e};
+    exp = {f, b, c, d, e};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is third
+    points = {e, f, a, b, c, d};
+    exp = {e, f, b, c, d};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is last
+    points = {b, c, d, e, f, a};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is second
+    points = {f, a, b, c, d, e};
+    exp = {f, b, c, d, e};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Points a and g are collinear, using a through g, and a comes first
+    points = {a, b, c, d, e, f, g};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes second
+    points = {g, a, b, c, d, e, f};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes third
+    points = {f, g, a, b, c, d, e};
+    exp = {f, b, c, d, e};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes second to last
+    points = {c, d, e, f, g, a, b};
+    exp = {c, d, e, f, b};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes last
+    points = {b, c, d, e, f, g, a};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+}
+
+/* Test pathological case consisting of a many-sided regular polygon with every
+ * point locally soft collinear with its neighbors due to a large tolerance.
+ */
+TEST(PolygonUtilsTest, filter_collinear_points_pathological)
+{
+    // Create a many-sided regular polygon by placing 20 equally spaced points
+    // on a circule of radius = 1, in clockwise order.
+    VecReal2 points(20);
+    real_type step = static_cast<real_type>(2 * constants::pi / points.size());
path: test/orange/orangeinp/detail/PolygonUtils.test.cc
user: login: mrguilima
body: At this point, the cast is redundant.
```suggestion
    real_type step = 2 * constants::pi / static_cast<real_type>(points.size());
```
created_at: 2025-05-23T04:36:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -149,6 +162,117 @@ TEST(PolygonUtilsTest, convex_self_intersect)
     EXPECT_FALSE(is_convex(self_int2));
 }
 
+/* Test removal of collinear points using points a through g, which when
+ * traversed clockewise form a convex polygon.
+ *
+ *     c . . . . . d  |_ 2
+ *     .              |.
+ *     .              |     .
+ *    .               |         .
+ *    .               |_ 1          e
+ *    .               |           .
+ *   .                |          .
+ *   .                |         .
+ *   b________________a____g__f_________
+ *   |        |       |       |        |
+ *  -1       -0.5     0      0.5       1
+ */
+TEST(PolygonUtilsTest, filter_collinear_points)
+{
+    // Point locations, as labled above
path: test/orange/orangeinp/detail/PolygonUtils.test.cc
user: login: mrguilima
body: ```suggestion
    // Point locations, as labeled above
```
created_at: 2025-05-23T04:44:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -119,6 +87,84 @@ is_same_orientation(Orientation a, Orientation b, bool degen_ok = false)
     return (a == b);
 }
 
+//---------------------------------------------------------------------------//
+/*!
+ * Functor for calculating orientation with a tolerance for collinearity.
+ *
+ * Collinearity is based on a supplied absolute tolerance. For three ordered
+ * points a, b, c, point b is collinear if the displacement, d, is less than
+ * the absolute tolerance.
+ *
+ *              b
+ *             . .
+ *           .  .  .
+ *         .    .    .
+ *       .      . d    .
+ *     .  t     .        .
+ *   a . . . . . . . . . . c
+ *
+ * The displacement is calculated as follows.
+ *
+ * Let:
+ * u = b - a
+ * v = c - a
path: src/orange/orangeinp/detail/PolygonUtils.hh
user: login: mrguilima
body: ```suggestion
 * Let: \f[
 * \vec u = \vec b - \vec a
 * \vec v = \vec c - \vec a
```
created_at: 2025-05-23T04:47:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -119,6 +87,84 @@ is_same_orientation(Orientation a, Orientation b, bool degen_ok = false)
     return (a == b);
 }
 
+//---------------------------------------------------------------------------//
+/*!
+ * Functor for calculating orientation with a tolerance for collinearity.
+ *
+ * Collinearity is based on a supplied absolute tolerance. For three ordered
+ * points a, b, c, point b is collinear if the displacement, d, is less than
+ * the absolute tolerance.
+ *
+ *              b
+ *             . .
+ *           .  .  .
+ *         .    .    .
+ *       .      . d    .
+ *     .  t     .        .
+ *   a . . . . . . . . . . c
+ *
+ * The displacement is calculated as follows.
+ *
+ * Let:
+ * u = b - a
+ * v = c - a
+ *
+ * In 2D, the cross product is can be written as,
+ *
+ * u x v = |u| |v| sin(t),
+ *
+ * noting that this is a different cross product (different vectors) compared
+ * to the cross product used for orientation determination. Geometrically, the
+ * displacement can be calculated as,
+ *
+ * d = |u| sin(t).
path: src/orange/orangeinp/detail/PolygonUtils.hh
user: login: mrguilima
body: ```suggestion
 * d = |vec u| sin(t).
```
created_at: 2025-05-23T04:48:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -119,6 +87,84 @@ is_same_orientation(Orientation a, Orientation b, bool degen_ok = false)
     return (a == b);
 }
 
+//---------------------------------------------------------------------------//
+/*!
+ * Functor for calculating orientation with a tolerance for collinearity.
+ *
+ * Collinearity is based on a supplied absolute tolerance. For three ordered
+ * points a, b, c, point b is collinear if the displacement, d, is less than
+ * the absolute tolerance.
+ *
+ *              b
+ *             . .
+ *           .  .  .
+ *         .    .    .
+ *       .      . d    .
+ *     .  t     .        .
+ *   a . . . . . . . . . . c
+ *
+ * The displacement is calculated as follows.
+ *
+ * Let:
+ * u = b - a
+ * v = c - a
+ *
+ * In 2D, the cross product is can be written as,
+ *
+ * u x v = |u| |v| sin(t),
+ *
+ * noting that this is a different cross product (different vectors) compared
+ * to the cross product used for orientation determination. Geometrically, the
+ * displacement can be calculated as,
+ *
+ * d = |u| sin(t).
+ *
+ * Therefore,
+ *
+ * d = |u| (u x v) / (|u| |v|)
+ *   = (u x v)/|v|.
path: src/orange/orangeinp/detail/PolygonUtils.hh
user: login: mrguilima
body: ```suggestion
 * \f[
 * d = |\vec u| (\vec u \times \vec v) / (|\vec u| |\vec v|)
 *   = (\vec u \times \vec v)/|\vec v|.
 * \f]
```
created_at: 2025-05-23T04:49:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -119,6 +87,84 @@ is_same_orientation(Orientation a, Orientation b, bool degen_ok = false)
     return (a == b);
 }
 
+//---------------------------------------------------------------------------//
+/*!
+ * Functor for calculating orientation with a tolerance for collinearity.
+ *
+ * Collinearity is based on a supplied absolute tolerance. For three ordered
+ * points a, b, c, point b is collinear if the displacement, d, is less than
+ * the absolute tolerance.
+ *
+ *              b
+ *             . .
+ *           .  .  .
+ *         .    .    .
+ *       .      . d    .
+ *     .  t     .        .
+ *   a . . . . . . . . . . c
+ *
+ * The displacement is calculated as follows.
+ *
+ * Let:
+ * u = b - a
+ * v = c - a
path: src/orange/orangeinp/detail/PolygonUtils.hh
user: login: sethrj
body: Needs to be either two separate equations or use the split environment. Also I'm not too worried about equation rendering in detail classes; it's most important for classes rendered in the user manual.
created_at: 2025-05-23T11:49:09Z
author_association: MEMBER
diff_hunk: @@ -197,6 +197,14 @@ void to_json(nlohmann::json& j, EllipticalCone const& cr)
          SIO_ATTR_PAIR(cr, upper_radii),
          SIO_ATTR_PAIR(cr, halfheight)};
 }
+void to_json(nlohmann::json& j, ExtrudedPolygon const& cr)
+{
+    j = {
+        {"_type", "extrudedpolygon"}, SIO_ATTR_PAIR(cr, polygon),
+        // SIO_ATTR_PAIR(cr, line_segment),
+        // SIO_ATTR_PAIR(cr, scaling_factors)
path: src/orange/orangeinp/ObjectIO.json.cc
user: login: elliottbiondo
body: This is stale; it was resolved in a subsequent commit.
created_at: 2025-05-23T14:18:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -149,6 +162,118 @@ TEST(PolygonUtilsTest, convex_self_intersect)
     EXPECT_FALSE(is_convex(self_int2));
 }
 
+/* Test removal of collinear points using points a through g, which when
+ * traversed clockewise form a convex polygon.
+ *
+ *     c . . . . . d  |_ 2
+ *     .              |.
+ *     .              |     .
+ *    .               |         .
+ *    .               |_ 1          e
+ *    .               |           .
+ *   .                |          .
+ *   .                |         .
+ *   b________________a____g__f_________
+ *   |        |       |       |        |
+ *  -1       -0.5     0      0.5       1
+ */
+TEST(PolygonUtilsTest, filter_collinear_points)
+{
+    // Points locations, as labled above
+    Real2 a = {0, 0};
+    Real2 b = {-1, -1e-5};
+    Real2 c = {-0.9, -0.1};
+    Real2 d = {0.75, 1};
+    Real2 e = {0.75, 0.5};
+    Real2 f = {0.5, 1e-5};
+    Real2 g = {0.35, 1e-6};
+
+    using VecReal2 = std::vector<Real2>;
+    real_type tol = 0.01;
+
+    // no collinear points (b through f)
+    VecReal2 points{b, c, d, e, f};
+    VecReal2 exp = points;
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Point a is collinear, using a through f, and a comes first
+    points = {a, b, c, d, e, f};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is second
+    points = {f, a, b, c, d, e};
+    exp = {f, b, c, d, e};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is third
+    points = {e, f, a, b, c, d};
+    exp = {e, f, b, c, d};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is last
+    points = {b, c, d, e, f, a};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is second
+    points = {f, a, b, c, d, e};
+    exp = {f, b, c, d, e};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Points a and g are collinear, using a through g, and a comes first
+    points = {a, b, c, d, e, f, g};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes second
+    points = {g, a, b, c, d, e, f};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes third
+    points = {f, g, a, b, c, d, e};
+    exp = {f, b, c, d, e};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes second to last
+    points = {c, d, e, f, g, a, b};
+    exp = {c, d, e, f, b};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes last
+    points = {b, c, d, e, f, g, a};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+}
+
+/* Test pathological case consisting of a many-sided regular polygon with every
+ * point locally soft collinear with its neighbors due to a large tolerance.
+ */
+TEST(PolygonUtilsTest, filter_collinear_points_pathological)
+{
+    // Create a many-sided regular polygon by placing 20 equally spaced points
+    // on a circule of radius = 1, in clockwise order.
path: test/orange/orangeinp/detail/PolygonUtils.test.cc
user: login: elliottbiondo
body: (also stale)
created_at: 2025-05-23T14:24:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -149,6 +162,118 @@ TEST(PolygonUtilsTest, convex_self_intersect)
     EXPECT_FALSE(is_convex(self_int2));
 }
 
+/* Test removal of collinear points using points a through g, which when
+ * traversed clockewise form a convex polygon.
+ *
+ *     c . . . . . d  |_ 2
+ *     .              |.
+ *     .              |     .
+ *    .               |         .
+ *    .               |_ 1          e
+ *    .               |           .
+ *   .                |          .
+ *   .                |         .
+ *   b________________a____g__f_________
+ *   |        |       |       |        |
+ *  -1       -0.5     0      0.5       1
+ */
+TEST(PolygonUtilsTest, filter_collinear_points)
+{
+    // Points locations, as labled above
+    Real2 a = {0, 0};
+    Real2 b = {-1, -1e-5};
+    Real2 c = {-0.9, -0.1};
+    Real2 d = {0.75, 1};
+    Real2 e = {0.75, 0.5};
+    Real2 f = {0.5, 1e-5};
+    Real2 g = {0.35, 1e-6};
+
+    using VecReal2 = std::vector<Real2>;
+    real_type tol = 0.01;
+
+    // no collinear points (b through f)
+    VecReal2 points{b, c, d, e, f};
+    VecReal2 exp = points;
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Point a is collinear, using a through f, and a comes first
+    points = {a, b, c, d, e, f};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is second
+    points = {f, a, b, c, d, e};
+    exp = {f, b, c, d, e};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is third
+    points = {e, f, a, b, c, d};
+    exp = {e, f, b, c, d};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is last
+    points = {b, c, d, e, f, a};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is second
+    points = {f, a, b, c, d, e};
+    exp = {f, b, c, d, e};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Points a and g are collinear, using a through g, and a comes first
+    points = {a, b, c, d, e, f, g};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes second
+    points = {g, a, b, c, d, e, f};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes third
+    points = {f, g, a, b, c, d, e};
+    exp = {f, b, c, d, e};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes second to last
+    points = {c, d, e, f, g, a, b};
+    exp = {c, d, e, f, b};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes last
+    points = {b, c, d, e, f, g, a};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+}
+
+/* Test pathological case consisting of a many-sided regular polygon with every
+ * point locally soft collinear with its neighbors due to a large tolerance.
+ */
+TEST(PolygonUtilsTest, filter_collinear_points_pathological)
+{
+    // Create a many-sided regular polygon by placing 20 equally spaced points
+    // on a circule of radius = 1, in clockwise order.
+    VecReal2 points(20);
+    real_type step = static_cast<real_type>(2 * constants::pi / points.size());
+
+    for (auto i : range(points.size()))
+    {
+        real_type theta = -step * i;
+        points[i] = {std::cos(theta), std::sin(theta)};
+    }
+
+    // Verify choose a tolerance such that the points are locally soft
+    // collinear
+    real_type tol = 0.1;
+    SoftOrientation soft_ori(tol);
+    EXPECT_EQ(Orientation::collinear,
+              soft_ori(points[0], points[1], points[2]));
+
+    // Check that filtering gives us more that zero points, in this case 7
path: test/orange/orangeinp/detail/PolygonUtils.test.cc
user: login: elliottbiondo
body: (also stale)
created_at: 2025-05-23T14:25:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -149,6 +162,118 @@ TEST(PolygonUtilsTest, convex_self_intersect)
     EXPECT_FALSE(is_convex(self_int2));
 }
 
+/* Test removal of collinear points using points a through g, which when
+ * traversed clockewise form a convex polygon.
+ *
+ *     c . . . . . d  |_ 2
+ *     .              |.
+ *     .              |     .
+ *    .               |         .
+ *    .               |_ 1          e
+ *    .               |           .
+ *   .                |          .
+ *   .                |         .
+ *   b________________a____g__f_________
+ *   |        |       |       |        |
+ *  -1       -0.5     0      0.5       1
+ */
+TEST(PolygonUtilsTest, filter_collinear_points)
+{
+    // Points locations, as labled above
+    Real2 a = {0, 0};
+    Real2 b = {-1, -1e-5};
+    Real2 c = {-0.9, -0.1};
+    Real2 d = {0.75, 1};
+    Real2 e = {0.75, 0.5};
+    Real2 f = {0.5, 1e-5};
+    Real2 g = {0.35, 1e-6};
+
+    using VecReal2 = std::vector<Real2>;
+    real_type tol = 0.01;
+
+    // no collinear points (b through f)
+    VecReal2 points{b, c, d, e, f};
+    VecReal2 exp = points;
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Point a is collinear, using a through f, and a comes first
+    points = {a, b, c, d, e, f};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is second
+    points = {f, a, b, c, d, e};
+    exp = {f, b, c, d, e};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is third
+    points = {e, f, a, b, c, d};
+    exp = {e, f, b, c, d};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is last
+    points = {b, c, d, e, f, a};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a is second
+    points = {f, a, b, c, d, e};
+    exp = {f, b, c, d, e};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Points a and g are collinear, using a through g, and a comes first
+    points = {a, b, c, d, e, f, g};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes second
+    points = {g, a, b, c, d, e, f};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes third
+    points = {f, g, a, b, c, d, e};
+    exp = {f, b, c, d, e};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes second to last
+    points = {c, d, e, f, g, a, b};
+    exp = {c, d, e, f, b};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+
+    // Same, but a comes last
+    points = {b, c, d, e, f, g, a};
+    exp = {b, c, d, e, f};
+    expect_polygon_equal(exp, filter_collinear_points(points, tol));
+}
+
+/* Test pathological case consisting of a many-sided regular polygon with every
+ * point locally soft collinear with its neighbors due to a large tolerance.
+ */
+TEST(PolygonUtilsTest, filter_collinear_points_pathological)
+{
+    // Create a many-sided regular polygon by placing 20 equally spaced points
+    // on a circule of radius = 1, in clockwise order.
+    VecReal2 points(20);
+    real_type step = static_cast<real_type>(2 * constants::pi / points.size());
+
+    for (auto i : range(points.size()))
+    {
+        real_type theta = -step * i;
+        points[i] = {std::cos(theta), std::sin(theta)};
+    }
+
+    // Verify choose a tolerance such that the points are locally soft
path: test/orange/orangeinp/detail/PolygonUtils.test.cc
user: login: elliottbiondo
body: (also stale)
created_at: 2025-05-23T14:25:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -316,6 +318,97 @@ class EllipticalCone final : public IntersectRegionInterface
     real_type hh_;
 };
 
+//---------------------------------------------------------------------------//
+/*!
+ * Region formed by extruding + scaling a convex polygon along a line segment.
+ *
+ * The convex polygon is supplied as a set of points on the XY plane in
+ * clockwise order. The line segment and scaling factors are specified by
+ * providing a line segment point and scaling factor for the top and bottom
+ * polygon faces of the region. The line segment point of the top face must
+ * have a z value greater than that of the bottom face. Along the line segment,
+ * the size of the polygon is linearly scaled in accordance with scaling
+ * factors.
+ *
+ * As is done in Geant4, construction is done by first applying scaling factors
+ * to the upper and lower polygons via scalar multiplication with each polygon
+ * point, then the points on the line are used to offset the upper and lower
+ * polygons.
+ */
+class ExtrudedPolygon final : public IntersectRegionInterface
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using VecReal2 = std::vector<Real2>;
+
+    //! Specifies the top or bottom face of the ExtrudedPolygon
+    struct PolygonFace
+    {
+        //! Start or end point of the line segment the polygon is extruded
+        //! along
+        Real3 line_segment_point{};
+
+        //! The fractional amount this face is scaled
+        real_type scaling_factor{};
+    };
+    //!@}
+
+  public:
+    // Construct from a convex polygon and bottom/top faces
+    ExtrudedPolygon(VecReal2 const& polygon,
+                    PolygonFace const& bot_face,
+                    PolygonFace const& top_face);
+
+    // Build surfaces
+    void build(IntersectSurfaceBuilder&) const final;
+
+    // Output to JSON
+    void output(JsonPimpl*) const final;
+
+    //// ACCESSORS ////
+
+    //! Polygon points (2D)
+    VecReal2 polygon() const { return polygon_; }
+
+    //! Bottom point of the line segment
+    Real3 bot_line_segment_point() const { return line_segment_[Bound::lo]; }
+
+    //! Top point of the line segment
+    Real3 top_line_segment_point() const { return line_segment_[Bound::hi]; }
+
+    //! Bottom scaling factor
+    real_type bot_scaling_factor() const
+    {
+        return scaling_factors_[Bound::lo];
+    }
+
+    //! Top scaling factor
+    real_type top_scaling_factor() const
+    {
+        return scaling_factors_[Bound::hi];
+    }
+
+  private:
+    //// TYPES ////
+    using Range = celeritas::Array<real_type, 2>;
path: src/orange/orangeinp/IntersectRegion.hh
user: login: sethrj
body: Range/range are confusing given `corecel/cont/Range.hh`... I might've gone with `Real2 lo_, hi_;` for points but whatever, let's just get this in.
created_at: 2025-05-23T14:47:08Z
author_association: MEMBER
diff_hunk: @@ -119,6 +87,84 @@ is_same_orientation(Orientation a, Orientation b, bool degen_ok = false)
     return (a == b);
 }
 
+//---------------------------------------------------------------------------//
+/*!
+ * Functor for calculating orientation with a tolerance for collinearity.
+ *
+ * Collinearity is based on a supplied absolute tolerance. For three ordered
+ * points a, b, c, point b is collinear if the displacement, d, is less than
+ * the absolute tolerance.
+ *
path: src/orange/orangeinp/detail/PolygonUtils.hh
user: login: sethrj
body: Can you put this (and future ASCII art) in [`\verbatim` blocks](https://www.doxygen.nl/manual/commands.html#cmdverbatim)? Otherwise it will render as mush.
created_at: 2025-05-23T14:49:04Z
author_association: MEMBER
diff_hunk: @@ -316,6 +318,97 @@ class EllipticalCone final : public IntersectRegionInterface
     real_type hh_;
 };
 
+//---------------------------------------------------------------------------//
+/*!
+ * Region formed by extruding + scaling a convex polygon along a line segment.
+ *
+ * The convex polygon is supplied as a set of points on the XY plane in
+ * clockwise order. The line segment and scaling factors are specified by
+ * providing a line segment point and scaling factor for the top and bottom
+ * polygon faces of the region. The line segment point of the top face must
+ * have a z value greater than that of the bottom face. Along the line segment,
+ * the size of the polygon is linearly scaled in accordance with scaling
+ * factors.
+ *
+ * As is done in Geant4, construction is done by first applying scaling factors
+ * to the upper and lower polygons via scalar multiplication with each polygon
+ * point, then the points on the line are used to offset the upper and lower
+ * polygons.
+ */
+class ExtrudedPolygon final : public IntersectRegionInterface
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using VecReal2 = std::vector<Real2>;
+
+    //! Specifies the top or bottom face of the ExtrudedPolygon
+    struct PolygonFace
+    {
+        //! Start or end point of the line segment the polygon is extruded
+        //! along
+        Real3 line_segment_point{};
+
+        //! The fractional amount this face is scaled
+        real_type scaling_factor{};
+    };
+    //!@}
+
+  public:
+    // Construct from a convex polygon and bottom/top faces
+    ExtrudedPolygon(VecReal2 const& polygon,
+                    PolygonFace const& bot_face,
+                    PolygonFace const& top_face);
+
+    // Build surfaces
+    void build(IntersectSurfaceBuilder&) const final;
+
+    // Output to JSON
+    void output(JsonPimpl*) const final;
+
+    //// ACCESSORS ////
+
+    //! Polygon points (2D)
+    VecReal2 polygon() const { return polygon_; }
+
+    //! Bottom point of the line segment
+    Real3 bot_line_segment_point() const { return line_segment_[Bound::lo]; }
+
+    //! Top point of the line segment
+    Real3 top_line_segment_point() const { return line_segment_[Bound::hi]; }
+
+    //! Bottom scaling factor
+    real_type bot_scaling_factor() const
+    {
+        return scaling_factors_[Bound::lo];
+    }
+
+    //! Top scaling factor
+    real_type top_scaling_factor() const
+    {
+        return scaling_factors_[Bound::hi];
+    }
+
+  private:
+    //// TYPES ////
+    using Range = celeritas::Array<real_type, 2>;
path: src/orange/orangeinp/IntersectRegion.hh
user: login: elliottbiondo
body: I think using the most appropriate data type (i.e., `celeritas::Array<real_type, 2>`) is more important than coming up with an ideal name for the data type. It's not ideal to give it a name that conflicts with another type elsewhere, but there is an extremely limited number of words that can describe this concept. `Range` and `Span` are taken. Maybe `Interval`.

FWIW I prefer more descriptive type names. As an example elsewhere in this MR, I would prefer call `Array<real_type, 2>` a `LineSegment`. I specifically used `ArrayReal` for this within in the MR based on previous MR comments (https://github.com/celeritas-project/celeritas/pull/1738#discussion_r2044206267).
created_at: 2025-05-23T15:40:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -119,6 +87,84 @@ is_same_orientation(Orientation a, Orientation b, bool degen_ok = false)
     return (a == b);
 }
 
+//---------------------------------------------------------------------------//
+/*!
+ * Functor for calculating orientation with a tolerance for collinearity.
+ *
+ * Collinearity is based on a supplied absolute tolerance. For three ordered
+ * points a, b, c, point b is collinear if the displacement, d, is less than
+ * the absolute tolerance.
+ *
+ *              b
+ *             . .
+ *           .  .  .
+ *         .    .    .
+ *       .      . d    .
+ *     .  t     .        .
+ *   a . . . . . . . . . . c
+ *
+ * The displacement is calculated as follows.
+ *
+ * Let:
+ * u = b - a
+ * v = c - a
path: src/orange/orangeinp/detail/PolygonUtils.hh
user: login: elliottbiondo
body: @sethrj why does this need to be two separate equations?
created_at: 2025-05-23T15:48:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -316,6 +318,97 @@ class EllipticalCone final : public IntersectRegionInterface
     real_type hh_;
 };
 
+//---------------------------------------------------------------------------//
+/*!
+ * Region formed by extruding + scaling a convex polygon along a line segment.
+ *
+ * The convex polygon is supplied as a set of points on the XY plane in
+ * clockwise order. The line segment and scaling factors are specified by
+ * providing a line segment point and scaling factor for the top and bottom
+ * polygon faces of the region. The line segment point of the top face must
+ * have a z value greater than that of the bottom face. Along the line segment,
+ * the size of the polygon is linearly scaled in accordance with scaling
+ * factors.
+ *
+ * As is done in Geant4, construction is done by first applying scaling factors
+ * to the upper and lower polygons via scalar multiplication with each polygon
+ * point, then the points on the line are used to offset the upper and lower
+ * polygons.
+ */
+class ExtrudedPolygon final : public IntersectRegionInterface
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using VecReal2 = std::vector<Real2>;
+
+    //! Specifies the top or bottom face of the ExtrudedPolygon
+    struct PolygonFace
+    {
+        //! Start or end point of the line segment the polygon is extruded
+        //! along
+        Real3 line_segment_point{};
+
+        //! The fractional amount this face is scaled
+        real_type scaling_factor{};
+    };
+    //!@}
+
+  public:
+    // Construct from a convex polygon and bottom/top faces
+    ExtrudedPolygon(VecReal2 const& polygon,
+                    PolygonFace const& bot_face,
+                    PolygonFace const& top_face);
+
+    // Build surfaces
+    void build(IntersectSurfaceBuilder&) const final;
+
+    // Output to JSON
+    void output(JsonPimpl*) const final;
+
+    //// ACCESSORS ////
+
+    //! Polygon points (2D)
+    VecReal2 polygon() const { return polygon_; }
+
+    //! Bottom point of the line segment
+    Real3 bot_line_segment_point() const { return line_segment_[Bound::lo]; }
+
+    //! Top point of the line segment
+    Real3 top_line_segment_point() const { return line_segment_[Bound::hi]; }
+
+    //! Bottom scaling factor
+    real_type bot_scaling_factor() const
+    {
+        return scaling_factors_[Bound::lo];
+    }
+
+    //! Top scaling factor
+    real_type top_scaling_factor() const
+    {
+        return scaling_factors_[Bound::hi];
+    }
+
+  private:
+    //// TYPES ////
+    using Range = celeritas::Array<real_type, 2>;
path: src/orange/orangeinp/IntersectRegion.hh
user: login: mrguilima
body: Actually `Array<real_type, 2>` is equivalent to Real2, it is a 2D-point, not a line segment.
created_at: 2025-05-23T17:16:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -316,6 +318,97 @@ class EllipticalCone final : public IntersectRegionInterface
     real_type hh_;
 };
 
+//---------------------------------------------------------------------------//
+/*!
+ * Region formed by extruding + scaling a convex polygon along a line segment.
+ *
+ * The convex polygon is supplied as a set of points on the XY plane in
+ * clockwise order. The line segment and scaling factors are specified by
+ * providing a line segment point and scaling factor for the top and bottom
+ * polygon faces of the region. The line segment point of the top face must
+ * have a z value greater than that of the bottom face. Along the line segment,
+ * the size of the polygon is linearly scaled in accordance with scaling
+ * factors.
+ *
+ * As is done in Geant4, construction is done by first applying scaling factors
+ * to the upper and lower polygons via scalar multiplication with each polygon
+ * point, then the points on the line are used to offset the upper and lower
+ * polygons.
+ */
+class ExtrudedPolygon final : public IntersectRegionInterface
+{
+  public:
+    //!@{
+    //! \name Type aliases
+    using VecReal2 = std::vector<Real2>;
+
+    //! Specifies the top or bottom face of the ExtrudedPolygon
+    struct PolygonFace
+    {
+        //! Start or end point of the line segment the polygon is extruded
+        //! along
+        Real3 line_segment_point{};
+
+        //! The fractional amount this face is scaled
+        real_type scaling_factor{};
+    };
+    //!@}
+
+  public:
+    // Construct from a convex polygon and bottom/top faces
+    ExtrudedPolygon(VecReal2 const& polygon,
+                    PolygonFace const& bot_face,
+                    PolygonFace const& top_face);
+
+    // Build surfaces
+    void build(IntersectSurfaceBuilder&) const final;
+
+    // Output to JSON
+    void output(JsonPimpl*) const final;
+
+    //// ACCESSORS ////
+
+    //! Polygon points (2D)
+    VecReal2 polygon() const { return polygon_; }
+
+    //! Bottom point of the line segment
+    Real3 bot_line_segment_point() const { return line_segment_[Bound::lo]; }
+
+    //! Top point of the line segment
+    Real3 top_line_segment_point() const { return line_segment_[Bound::hi]; }
+
+    //! Bottom scaling factor
+    real_type bot_scaling_factor() const
+    {
+        return scaling_factors_[Bound::lo];
+    }
+
+    //! Top scaling factor
+    real_type top_scaling_factor() const
+    {
+        return scaling_factors_[Bound::hi];
+    }
+
+  private:
+    //// TYPES ////
+    using Range = celeritas::Array<real_type, 2>;
path: src/orange/orangeinp/IntersectRegion.hh
user: login: elliottbiondo
body: Yep, I said "elsewhere in this MR"
created_at: 2025-05-23T17:35:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -527,6 +528,140 @@ void EllipticalCone::output(JsonPimpl* j) const
     to_json_pimpl(j, *this);
 }
 
+//---------------------------------------------------------------------------//
+// ExtrudedPolygon
+//---------------------------------------------------------------------------//
+/*!
+ * Construct from a convex polygon and bottom/top faces.
+ */
+ExtrudedPolygon::ExtrudedPolygon(ExtrudedPolygon::VecReal2 const& polygon,
+                                 ExtrudedPolygon::PolygonFace const& bot_face,
+                                 ExtrudedPolygon::PolygonFace const& top_face)
+    : line_segment_{bot_face.line_segment_point, top_face.line_segment_point}
+    , scaling_factors_{bot_face.scaling_factor, top_face.scaling_factor}
+
+{
+    constexpr auto bot = Bound::lo;
+    constexpr auto top = Bound::hi;
+
+    CELER_VALIDATE(polygon.size() >= 3,
+                   << "polygon must consist of at least 3 points");
+    CELER_VALIDATE(scaling_factors_[bot] > 0 && scaling_factors_[top] > 0,
+                   << "scaling factors must be positive");
+    CELER_VALIDATE(line_segment_[bot][Z] < line_segment_[top][Z],
+                   << "line segment must begin with lower z value");
+
+    // Calculate min/max x/y values, used as both characteristic lengths
+    // generating a floating-point tolerance, and generating surfaces for
+    // bounding box creation
+    x_range_ = this->calc_range(polygon, X);
+    y_range_ = this->calc_range(polygon, Y);
+
+    // Store only non-collinear points
+    Real3 const extents{
+        x_range_[1] - x_range_[0], y_range_[1] - y_range_[0], 0};
+    real_type abs_tol = ::celeritas::detail::BumpCalculator(
+        Tolerance<>::from_default())(extents);
+
+    polygon_ = detail::filter_collinear_points(polygon, abs_tol);
+
+    // After removing collinear points, at least 3 points must remain
+    CELER_VALIDATE(polygon_.size() >= 3,
+                   << "polygon must consist of at least 3 points");
+
+    // After removing collinear points, the polygon should have a *strictly*
+    // clockwise orientation, which also guarantees it is convex.
+    CELER_VALIDATE(
+        has_orientation(make_span(polygon_), detail::Orientation::clockwise),
+        << "polygon must be specified in strictly clockwise order");
path: src/orange/orangeinp/IntersectRegion.cc
user: login: sethrj
body: Our discussion on https://github.com/celeritas-project/celeritas/pull/1840/files#r2210769390 made me take a closer look at this. You're correct that Geant4 always stores these points as clockwise, but that seems to be backward from general computer graphics algorithms:  it seems like most assume counterclockwise winding of exterior faces. (ChatGPT's API summary table seems to, and all occurrences of "clockwise" in [Ericson's real-time collision detection](https://www.taylorfrancis.com/books/9780080474144) are in the word "counterclockwise".
created_at: 2025-07-18T10:09:27Z
author_association: MEMBER
diff_hunk: @@ -527,6 +528,140 @@ void EllipticalCone::output(JsonPimpl* j) const
     to_json_pimpl(j, *this);
 }
 
+//---------------------------------------------------------------------------//
+// ExtrudedPolygon
+//---------------------------------------------------------------------------//
+/*!
+ * Construct from a convex polygon and bottom/top faces.
+ */
+ExtrudedPolygon::ExtrudedPolygon(ExtrudedPolygon::VecReal2 const& polygon,
+                                 ExtrudedPolygon::PolygonFace const& bot_face,
+                                 ExtrudedPolygon::PolygonFace const& top_face)
+    : line_segment_{bot_face.line_segment_point, top_face.line_segment_point}
+    , scaling_factors_{bot_face.scaling_factor, top_face.scaling_factor}
+
+{
+    constexpr auto bot = Bound::lo;
+    constexpr auto top = Bound::hi;
+
+    CELER_VALIDATE(polygon.size() >= 3,
+                   << "polygon must consist of at least 3 points");
+    CELER_VALIDATE(scaling_factors_[bot] > 0 && scaling_factors_[top] > 0,
+                   << "scaling factors must be positive");
+    CELER_VALIDATE(line_segment_[bot][Z] < line_segment_[top][Z],
+                   << "line segment must begin with lower z value");
+
+    // Calculate min/max x/y values, used as both characteristic lengths
+    // generating a floating-point tolerance, and generating surfaces for
+    // bounding box creation
+    x_range_ = this->calc_range(polygon, X);
+    y_range_ = this->calc_range(polygon, Y);
+
+    // Store only non-collinear points
+    Real3 const extents{
+        x_range_[1] - x_range_[0], y_range_[1] - y_range_[0], 0};
+    real_type abs_tol = ::celeritas::detail::BumpCalculator(
+        Tolerance<>::from_default())(extents);
+
+    polygon_ = detail::filter_collinear_points(polygon, abs_tol);
+
+    // After removing collinear points, at least 3 points must remain
+    CELER_VALIDATE(polygon_.size() >= 3,
+                   << "polygon must consist of at least 3 points");
path: src/orange/orangeinp/IntersectRegion.cc
user: login: sethrj
body: Again based on our discussion about tolerances (https://github.com/celeritas-project/celeritas/pull/1840/files#r2210049221): I think it would be more correct to do this filtering in the `build` function, and only check for exactly collinear/concave points in the constructor. In practice though I think it's fine.
created_at: 2025-07-18T10:11:35Z
author_association: MEMBER
diff_hunk: @@ -527,6 +528,140 @@ void EllipticalCone::output(JsonPimpl* j) const
     to_json_pimpl(j, *this);
 }
 
+//---------------------------------------------------------------------------//
+// ExtrudedPolygon
+//---------------------------------------------------------------------------//
+/*!
+ * Construct from a convex polygon and bottom/top faces.
+ */
+ExtrudedPolygon::ExtrudedPolygon(ExtrudedPolygon::VecReal2 const& polygon,
+                                 ExtrudedPolygon::PolygonFace const& bot_face,
+                                 ExtrudedPolygon::PolygonFace const& top_face)
+    : line_segment_{bot_face.line_segment_point, top_face.line_segment_point}
+    , scaling_factors_{bot_face.scaling_factor, top_face.scaling_factor}
+
+{
+    constexpr auto bot = Bound::lo;
+    constexpr auto top = Bound::hi;
+
+    CELER_VALIDATE(polygon.size() >= 3,
+                   << "polygon must consist of at least 3 points");
+    CELER_VALIDATE(scaling_factors_[bot] > 0 && scaling_factors_[top] > 0,
+                   << "scaling factors must be positive");
+    CELER_VALIDATE(line_segment_[bot][Z] < line_segment_[top][Z],
+                   << "line segment must begin with lower z value");
+
+    // Calculate min/max x/y values, used as both characteristic lengths
+    // generating a floating-point tolerance, and generating surfaces for
+    // bounding box creation
+    x_range_ = this->calc_range(polygon, X);
+    y_range_ = this->calc_range(polygon, Y);
+
+    // Store only non-collinear points
+    Real3 const extents{
+        x_range_[1] - x_range_[0], y_range_[1] - y_range_[0], 0};
+    real_type abs_tol = ::celeritas::detail::BumpCalculator(
+        Tolerance<>::from_default())(extents);
+
+    polygon_ = detail::filter_collinear_points(polygon, abs_tol);
+
+    // After removing collinear points, at least 3 points must remain
+    CELER_VALIDATE(polygon_.size() >= 3,
+                   << "polygon must consist of at least 3 points");
+
+    // After removing collinear points, the polygon should have a *strictly*
+    // clockwise orientation, which also guarantees it is convex.
+    CELER_VALIDATE(
+        has_orientation(make_span(polygon_), detail::Orientation::clockwise),
+        << "polygon must be specified in strictly clockwise order");
path: src/orange/orangeinp/IntersectRegion.cc
user: login: elliottbiondo
body: Yeah, maybe we should change it so we do everything counterclockwise, then just reverse the points that Geant4 gives us. I agree that it's not good to inherit design decisions from other projects.
created_at: 2025-07-18T22:23:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -527,6 +528,140 @@ void EllipticalCone::output(JsonPimpl* j) const
     to_json_pimpl(j, *this);
 }
 
+//---------------------------------------------------------------------------//
+// ExtrudedPolygon
+//---------------------------------------------------------------------------//
+/*!
+ * Construct from a convex polygon and bottom/top faces.
+ */
+ExtrudedPolygon::ExtrudedPolygon(ExtrudedPolygon::VecReal2 const& polygon,
+                                 ExtrudedPolygon::PolygonFace const& bot_face,
+                                 ExtrudedPolygon::PolygonFace const& top_face)
+    : line_segment_{bot_face.line_segment_point, top_face.line_segment_point}
+    , scaling_factors_{bot_face.scaling_factor, top_face.scaling_factor}
+
+{
+    constexpr auto bot = Bound::lo;
+    constexpr auto top = Bound::hi;
+
+    CELER_VALIDATE(polygon.size() >= 3,
+                   << "polygon must consist of at least 3 points");
+    CELER_VALIDATE(scaling_factors_[bot] > 0 && scaling_factors_[top] > 0,
+                   << "scaling factors must be positive");
+    CELER_VALIDATE(line_segment_[bot][Z] < line_segment_[top][Z],
+                   << "line segment must begin with lower z value");
+
+    // Calculate min/max x/y values, used as both characteristic lengths
+    // generating a floating-point tolerance, and generating surfaces for
+    // bounding box creation
+    x_range_ = this->calc_range(polygon, X);
+    y_range_ = this->calc_range(polygon, Y);
+
+    // Store only non-collinear points
+    Real3 const extents{
+        x_range_[1] - x_range_[0], y_range_[1] - y_range_[0], 0};
+    real_type abs_tol = ::celeritas::detail::BumpCalculator(
+        Tolerance<>::from_default())(extents);
+
+    polygon_ = detail::filter_collinear_points(polygon, abs_tol);
+
+    // After removing collinear points, at least 3 points must remain
+    CELER_VALIDATE(polygon_.size() >= 3,
+                   << "polygon must consist of at least 3 points");
path: src/orange/orangeinp/IntersectRegion.cc
user: login: elliottbiondo
body: Maybe we should touch base on this. I think the the convex hull code was the first place you suggested calculating an absolute tolerance based on the length scale. In principle, shouldn't we be doing this everywhere? My design decisions have been influenced by "where is the best place to calculate the abs tol." I was viewing collinear points as invalid data, and I don't like storing invalid data on a class. Why not DBC-expect that the supplied polygon is valid? Well, if you bring in the machinery for detecting polygon validity, you might as well just fix in place.
created_at: 2025-07-18T22:36:08Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-13T21:25:15Z
body: ## Test summary
 4 401 files   6 753 suites   13m 20s :stopwatch:
 1 812 tests  1 803 :white_check_mark:  9 :zzz: 0 :x:
23 128 runs  23 043 :white_check_mark: 85 :zzz: 0 :x:

Results for commit 76c6be3c.

[test-results]:data:application/gzip;base64,H4sIALK/MGgC/03Myw6DIBCF4VcxrLsAhpt9GQN0TEi9NAirpu/e0ap1N+eb5H+zPg24sHujFBe3hi01lW0bq4H2o2Zf0jyROM4J6FvWv3BCHrNbaoybcfjbM73I2hN6nwaCM9JhznPeJddprUoQ0u3zqErgCk77VZ0+4FLd9jUa53FMhQazJpqAEE3rNepguBKqBbTB0m2FxIgIvQzs8wWKODjYEgEAAA==

:recycle: This comment has been updated with latest results.
author_association: NONE
user: login: elliottbiondo
created_at: 2025-05-14T16:37:07Z
body: @mrguilima this is still a draft MR, but I will address your comments.
author_association: CONTRIBUTOR
user: login: elliottbiondo
created_at: 2025-05-14T18:27:21Z
body: This is going to remain as draft for now. I realize that I should actually be discarding colinear points.
author_association: CONTRIBUTOR
user: login: elliottbiondo
created_at: 2025-05-15T20:00:15Z
body: @mrguilima @sethrj this is ready to review
author_association: CONTRIBUTOR
user: login: elliottbiondo
created_at: 2025-05-21T16:02:00Z
body: @sethrj I am also going to change up what it means to be soft colinear. For hard collinearity, the cross product makes sense because only the sign matters. For soft collinearity, we should be comparing α to an absolute tolerance (i.e., the bump distance), where α is:

![Screenshot 2025-05-21 at 11 59 42 AM](https://github.com/user-attachments/assets/775a7986-b661-41a9-829d-d4b8c2cde722)

author_association: CONTRIBUTOR
user: login: sethrj
created_at: 2025-05-21T16:04:28Z
body: Good point!
author_association: MEMBER
user: login: elliottbiondo
created_at: 2025-05-22T22:02:42Z
body: @mrguilima @sethrj this should be ready
author_association: CONTRIBUTOR
user: login: elliottbiondo
created_at: 2025-05-23T15:53:22Z
body: @mrguilima @sethrj I fixed the equations in `src/orange/surf/Plane.hh` to use LaTeX. For the stuff in `detail` directory, I am actually going to advocate for not using LaTeX. It comes down to how important it is to have nice Doxygen for detail classes. Personally, I never use Doxygen; I always just go into the code, even for projects that I am a developer of (e.g., Geant4). The equations I have in `PolygonUtils` are really "developer equations," describing gritty implementation details, and I would prefer to read them in plain text rather than unrendered LaTeX.
author_association: CONTRIBUTOR
user: login: sethrj
created_at: 2025-05-23T16:23:26Z
body: I'm fine with this. Just add verb markup or html pre tags around it so it doesn't mess up the developer documentation which does include details
author_association: MEMBER
user: login: mrguilima
created_at: 2025-05-23T17:09:51Z
body: > @mrguilima @sethrj The equations I have in `PolygonUtils` are really "developer equations," describing gritty implementation details, and I would prefer to read them in plain text rather than unrendered LaTeX.

Sure.  I didn't know about the 'detail' classes not having their docs rendered, that's why I suggested the LaTeX formatting.

author_association: CONTRIBUTOR