# Changelog
All notable changes to this crate are documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this crate adheres to [Semantic Versioning](https://semver.org/).
Pre-1.0: the minor version is the breaking-change slot, per Cargo's own
caret rule for `0.x` versions.
## [Unreleased]
## [0.3.21] - 2026-10-28
### Changed
- The licence is now `MPL-2.0 OR LicenseRef-Commercial` (was `MPL-2.0`):
a commercial licence is available from the copyright holder
(`LICENSE-COMMERCIAL`, ADR 0089, #358).
- The minimum supported Rust version is now 1.99 (was 1.88), and the
workspace builds on Rust 1.99.1 (#349).
## [0.3.20] - 2026-10-20
### Fixed
- `section_lower::rectangle_contour` accepts a corner radius equal to its
half-extent after unit conversion (#339). A radius above its limit by at
most four relative epsilons of the smaller half-extent the limit is cut
from is rounding and is snapped onto the limit, for the outer and the
inner radius alike; one wider than that (a micrometre, say) is still
refused by name. buildingSMART's IFC4 Annex E `air-terminal-element.ifc`
tube (24 x 24 in, 2 in wall, 10 in inner fillet, in metres) used to be
refused with "inner corner radius must lie in [0, 0.25399999999999995],
got 0.254" and now builds with a fully rounded core. The structural
sections have no such bound: their radii meet only the shared router's
edge check, which already allows `1e-12`.
## [0.3.19] - 2026-10-16
### Added
- `half_space::bounded_half_space_prism` (#321): the slab of a half-space
bounded by the prism of a boundary along any axis that crosses the
plane, oblique to it allowed. The near cap is the boundary carried along
the axis onto the plane, the far cap the same at the depth: a
`SlabDepth`, `Relative` to the boundary's extent (as before) or the
`Reach` of a subject. `BoundaryPrism` states where the boundary stands
(`origin`, `x`, `y`, used as given) and the `axis`;
`BoundaryPrism::along_placement_z` reads it from a placement, IFC's
`IfcPolygonalBoundedHalfSpace.Position`, `BoundaryPrism::along_normal`
from a plane frame as `bounded_half_space_in_frame` does, and
`BoundaryPrism::crossing` the cosine of the axis against the plane.
Refused by name: an axis within the angular tolerance of the plane
(`"parallel to the clip plane"`), a degenerate placement (axes parallel
or zero) and a depth that is not positive and finite.
- `BoundaryPrism::along_plane_normal` (#322): a boundary placed by a whole
placement along the plane normal, the placement's `x` projected into the
plane, its scale, shear and handedness kept; a degenerate placement
(parallel or zero axes) is refused by name.
### Changed
- `bounded_half_space_in_frame` honours a frame mirrored about the plane
normal, whose `y` is `-(normal x x)` (#322): its boundary is built
mirrored, as written, still outward-facing. It used to rebuild `y` as
`normal x x` and build the mirror image. A frame standing perpendicular
to the plane has no handedness there and is refused by name.
### Fixed
- A bounded half-space whose boundary ring runs clockwise is built
outward-facing, the ring read reversed (the same region); it came out
inside-out before.
- A derived contour's arcs under a scaling transform are scaled once
(#322): the scale went into the radius and into the arc's frame axes, so
every arc came out scaled twice, its contour open, and the extrusion
refused (`tests/derived_composite.rs`).
## [0.3.18] - 2026-10-13
### Added
- `loft_exact::loft_polygon_stations_exact` (#247): an exact closed B-rep
of planar faces joining planar polygon sections vertex for vertex, caps
with holes and one planar wall per ring edge and span; a wall whose
corners are not coplanar is refused by name (`loft_exact::WARPED_WALL`).
- `loft::quad_deviation` is public (#247): the bound a station run's mesh
cells use for the wall quad against its two triangles.
- **Keyhole faces of a closed shell triangulate without a seam edge
(#283, ADR 0083 amendment).** `profile::triangulate_shell_face` is
`triangulate_with(.., PinchPolicy::Accept)` for a planar face of a
shell: no triangle edge lies on a seam (#270). Where the clipper would
make the seam a triangle edge, the two triangles on it are replaced by
the other diagonal of their quadrilateral when it is strictly convex,
and otherwise by four triangles around a Steiner point strictly inside
it, off the seam's line (a thin rim seamed at its corner always takes
one). A `SteinerPoint` is a chain of midpoints of the face's own points
(the seam's midpoint halved towards a corner, one halving past the
first that fits, as a margin against a caller's projection rounding),
which `SteinerPoint::place` replays over any point type, so a caller
lifting the face into space places it exactly in an axis plane. The
result (`ShellFaceTriangulation`: points `outer ++ holes ++ steiner`,
triangles, Steiner points) is certified against the cover it replaces:
every triangle strictly counter-clockwise, every directed edge once,
the same boundary, two more triangles per Steiner point, no edge on a
seam; a seam no point can be placed off is refused by name. Rings
without a seam triangulate exactly as under `Accept`. Tests
(`tests/profile_pinches.rs`): every keyhole fixture from every start,
either way round and under every zero-sign pattern, tiled exactly with
no seam edge; the thin rim needs a Steiner point; the off-grid rim
within rounding. Mutation probe:
`scripts/probe_keyhole_shell_mutants.py`.
- **Swept disks mitre a corner beside an arc (#248).** Such a corner was
refused by name; it is now mitred at half angle like a corner between
two segments, which is what the format defines. The mitre plane cuts
the bend's torus in a different curve than the tube on its other side,
so the end of the bend is meshed as a `pipe::MitreBand`: from a square
section `back` short of the corner, each profile vertex walks along
its own circle of the torus to the plane (every band vertex on the
exact torus), and a strip just short of the plane joins it to the ring
shared across the mitre, meshing the planar face between the two
sections. `back` and `ahead` are the least turns at which the section
short of and past the corner clears the plane by `0.01 r`, from the
closed form of the height's extremes over a section, so every circle
of the torus crosses the plane once between them; a corner whose plane does not clear
the bend's tube within a quarter turn, and bands needing more of an arc
than it turns, are refused by name. A segment beside the arc keeps its
own mitre ring, so its proof (#245) stands; the band and the strip are
certified by the caller, which `pipe::pipe_plan` serves
(`PipePlan::arc_mitres`, `ArcMitre`, `MitreSide`).
- **Swept disks round closed directrices (#248).**
`pipe::swept_disk_along_loop` sweeps pieces whose last ends where the
first starts: that joint is mitred, filleted or tangent like any other,
and the walls close on the first station without caps. A frame carried
round a non-planar loop comes back turned by the loop's holonomy
(`PipePlan::twist`, in `(-pi, pi]` about the first tangent); the pipe
turns its frames back by it uniformly over the loop's clear length
(segments outside the `r tan(theta/2)` mitre zones, bends outside their
bands), holding the frame in the zones so every mitre still shares one
ring on both cylinders. The twist is inside the proven bound (the span
bound measures it; a twisted segment gets stations until each span's
twist fits, more than 4096 is refused with `BudgetExceeded`), and the
last span pays its measured distance from the first station. A loop
whose frame comes back turned with no clear length to take it is
refused by name.
- `loft::Station` and `loft::Frame` derive `Debug` and `Clone`.
### Documented
- A disk radius equal to a fillet or bend radius stays refused by name
(#248), though the format permits it: the bend is a horn torus whose
inner wall pinches to one point on its axis, a boundary that is not a
two-manifold, which a closed mesh could only meet at a non-manifold
vertex or misstate by standing off it.
## [0.3.17] - 2026-10-09
### Fixed
- **Oblique exact extrusions of profiles with arcs are the sheared prism
(#280).** For an offset leaning off the profile normal, the arc
extruder (rounded rectangles, contours with arcs or round holes,
sections with fillets, centre-line profiles, composites) built each arc
wall as a right `Cylinder` standing on the profile plane and left the
far rim's edges over the unsheared plan section, while the far vertices
and cap were sheared: the solid was wrong by the horizontal shear
(about 0.25 in the geometric audit for `(0.3, -0.2, 1)` at depth 0.75)
and nothing refused it. Since #275 the downward case inherited it
through the mirror. The far rim is now the base rim moved by the whole
offset, and an arc sweeps the oblique circular cylinder it is: an
`EllipticalCylinder` with its axis along the unit direction `d`,
semi-axes `r` (across the lean) and `r d.z`, and rims traced by
`Sinusoid2` pcurves (`v = H / d.z - r |d_h| sin u`), all closed form.
Along the normal the walls stay right cylinders, unchanged.
### Changed
- **An oblique circle extrudes exactly (#280)** instead of being refused
as `"oblique circle extrusion"`: its wall is the same oblique
cylinder, its seam at the wall's angle origin. An oblique ellipse is
still refused, as `"oblique ellipse extrusion"`: its swept section has
principal axes other than the profile's. Tests
(`tests/oblique_extrusion.rs`): the issue's repro, and every family
with arcs (rounded and hollow rounded rectangles, a round hole, a round
contour, a 270-degree arc, a curved centre line, a circle, a derived
and a composite profile, a filleted I section) along four leaning
directions, up and down, each audited clean, of volume
`area * depth * |d.z|` (a closed form, or the straight prism of the
same height), with far vertices the base ones moved by the offset and
wall points that shear back onto the profile's circles; the downward
family sweep now covers oblique curved families too. Mutation probe:
`scripts/probe_oblique_extrusion_mutants.py`.
- **Behaviour change: the mesh extrusion refuses a direction in the
profile plane (#281)**, as the exact path does. `extrude_profile`
returned a sliver solid for an offset within tolerance of the profile
plane, and a flat zero-volume "solid" at exactly `o.z = 0`; it now
refuses `|o.z| <= tolerance` with the exact path's typed refusal,
`UnsupportedInput { operation: Sweep, input: "extrusion direction in
the profile plane" }`. `extrude`, which takes no tolerance, refuses an
offset exactly in the plane by the same name. A caller that relied on
the degenerate mesh now gets the error; `axiolid-mesh-compile` meshes
extrusions through `extrude_profile`, so it refuses them too. Test:
both paths refuse `z = 0`, `-0.0` and `+-tol / 2`, and build `+-2 tol`
with the same closed-form volume. Mutation probe:
`scripts/probe_oblique_extrusion_mutants.py`.
## [0.3.16] - 2026-10-09
### Added
- `profile::ring_touches` and `profile::RingTouch` (#265): every vertex
lying inside another ring edge, which `triangulate_with` under
`PinchPolicy::Accept` inserts into that edge, found by the same exact
validation and listed once per vertex and edge. A caller whose rings
share their edges with neighbouring patches (the faces of a closed
shell) splits the neighbour's copy of each such edge at the same vertex,
so the patches do not meet at a T-junction.
- **A ring joining its hole by a seam run both ways triangulates on
surface paths (#270).** Exporters write a planar face with a hole as one
ring: the outer boundary, a seam to the hole, the hole, and the seam
back (a keyhole). It bounds the outer polygon minus the hole, but the
certified clipper refused it as "outer ring overlaps itself".
`triangulate_with(.., PinchPolicy::Accept)` now takes a seam -- an edge
and a later edge of one ring between the same two points in opposite
directions, used by no other edge, nesting with the ring's other seams
and met by no other edge except at its ends -- out of the ring, splits
the ring into the loops it joins and triangulates them on the pinch
path (ADR 0083 amendment). The seam's two copies cancel, so the
certificate over the loops is the ring's; its ends are repeated
vertices and triangles use a point's first index. A hole loop of either
winding is a hole, a seam between two loops side by side leaves two
parts, and a keyhole may touch other rings at points. Refused by name:
a "seam" running back along only part of an edge, one edge twice in one
direction or three times, interleaving seams, seams in a row, an edge
crossing, touching or running along a seam, and a loop folding back
where its seam left it. `profile::ring_touches` (#265) validates such a
ring the same way and lists its loops' touches by the ring's own edge
and vertex indices.
### Changed
- `triangulate` and `PinchPolicy::Refuse` (extrusion and loft caps)
refuse a ring with a seam as "profile outer ring runs along a seam both
ways (edges i and j), which a solid cannot extrude" instead of "profile
outer ring intersects itself" (#270): an extruded keyhole puts two
coincident wall faces on the seam. Tests for #270: the issue's keyhole
(hole wound either way), a thin rim with a 2^-13 m seam and one at
0.1 mm, a seam from inside an outer edge to a hole's corner, two
keyholes in one ring, a keyhole with a pinch, a seam between loops side
by side, each from every start vertex, either way round and under eight
zero-sign patterns with the exact-tiling checks (the seam's copies
cancelling in the edge cover), random keyhole layouts, every refusal
above by name, and the solid refusals.
### Fixed
- **Exact extrusions whose direction points against the profile normal
build (#275).** `extrude_profile_exact` refused any offset with
`offset.z <= tolerance` as `"non-forward planar extrusion"`, so an
opening cut down from a slab's top (`ExtrudedDirection (0, 0, -1)`) had
no exact result and a boolean with it no certified mesh deviation. Such
an extrusion is the forward prism along `(o.x, o.y, -o.z)` mirrored in
the profile plane; it is now built that way, through
`ExactBRep::transformed`, for every profile family and for oblique
directions. Negating `z` is exact, so the profile-plane cap keeps the
profile's coordinates bit for bit, and the reflection flips every face,
so the solid stays outward with the forward prism's positive volume.
Only a direction within tolerance of the profile plane
(`|o.z| <= tolerance`) is refused, now named `"extrusion direction in
the profile plane"`. The mesh path already built these solids; both now
agree. Mutation probe: `scripts/probe_downward_extrusion_mutants.py`.
- **A ring's orientation is no longer misread at a `-0.0` twin of its
lowest corner (#269).** The certified clipper reads a ring's turn at its
lexicographically smallest vertex, found with `f64::total_cmp`, which
orders `-0.0` before `0.0`. A rectangle with a straight corner at
`(-0.0, 2)` and its true lowest corner at `(0, 0)` was read at the
straight corner, taken for clockwise and reversed, and both pinch
policies then refused it with "found no ear". Projecting a face onto
plane axes writes exactly such rings (`0.0 * -k = -0.0` next to the
origin's `0.0`); a downstream triage found 35 refused rectangular faces
in two models. Every coordinate comparison of the clipper now orders by
value, `-0.0` equal to `0.0` (still total and deterministic): the lowest
corner of a ring and of a pinch path's boundary cycle, the order of
touching vertices along an edge, the bridge order of holes and the edge
sweep. `polyhedron::triangulate` and `offset::offset_solid` key corners
with `-0.0` read as `0.0` too, so a corner written both ways welds into
one vertex. Tests: the issue's rectangle and a hole with the same
corner under both policies, every pinch fixture under eight zero-sign
patterns, random hole and pinch layouts with zeros negated at random,
and a cube with a face written in `-0.0`.
## [0.3.15] - 2026-10-04
### Added
- **Rings that touch at single points triangulate on 2D and surface
paths (#262).** The certified clipper of #253 refuses rings touching at
one vertex, which is right for a solid (the extrusion shares one wall
edge between four faces) but refused valid regions a consumer re-feeds
from overlay output: two rooms meeting at a corner, an L-shaped room
wrapped round a column corner, a corridor eroded to a point, the same
pinch reported through even-odd fill, a room minus door zones touching
its walls. `profile::triangulate_with(rings, PinchPolicy)` makes the
choice explicit: `PinchPolicy::Refuse` is `triangulate` (extrusion and
loft caps, unchanged), `PinchPolicy::Accept` triangulates the region
(ADR 0083 amendment). Two edges may then meet at one point that is a
vertex of at least one of them; the vertex is inserted into the other
edge, coincident vertices become one (triangles use a point's first
index in `outer ++ holes`), each ring is split at its repeated vertices
into simple loops turned by their nesting depth (inside an odd number of
loops is inside), the loops' edges must alternate leaving and arriving
round each shared vertex (else the rings cross there, refused by name),
and each wedge there becomes one node, so the region falls into its
connected parts -- two lobes of a pinch separate, a hole touching the
outer ring merges with it, holes touching in a ring close off a part of
their own -- each bridged and clipped as before. The certificate is the
same over the split loops, with each visit of a pinch point counted as
its own vertex and `n + 2h - 2c` triangles for `c` parts. Rings touching
nowhere triangulate identically under both policies; crossing and
overlapping rings stay refused under both. Tests
(`tests/profile_pinches.rs`): the consumer's cases (rooms at a corner,
an L round a column, an eroded corridor, even-odd figure eights, a room
minus door zones), footprints touching at a corner, holes touching the
outer ring at a corner, inside an edge, at every corner and across the
region, holes touching each other at a corner, inside a side, at their
extreme corner and in a ring that closes off a part with its own hole,
and an outer ring pinched round a hole, each from every start vertex and
either way round, tile exactly (strictly counter-clockwise, exact area,
no vertex on a triangle, untwinned edges covering every ring edge once)
and are refused by `triangulate` and `extrude_profile` by name; 300
random grid layouts of checkerboard squares and touching diamonds tile
exactly, 200 off the grid tile, and layouts shrunk apart triangulate
identically under both policies; crossing at shared vertices and
overlapping rings are refused by name. Mutation probe:
`scripts/probe_ring_triangulation_mutants.py` (52/52 killed).
### Fixed
- **Profiles with several holes extrude to closed solids (#253).**
`profile::triangulate` delegated to `earcut`, which drops nodes where
the bridged ring runs straight on and lets only reflex nodes block an
ear. For two holes side by side in one horizontal band (a 4 x 4 outer
ring, 1 x 1 holes at x in [-1.5, -0.5] and [0.5, 1.5]) it returned 12
triangles of the right area instead of 14: one edge ran along the
band's bottom line past both holes' inner corners, a T-junction, so the
plain extrusion had 8 boundary edges and failed its volume and closure
checks. Triangulation is now the crate's own ear clipper, every
decision an exact `orient2d` sign (ADR 0083, superseding ADR 0015 for
this crate): holes are bridged in order of decreasing largest x from
their rightmost vertex, each bridge accepted only when it enters the
polygon at both ends and touches no ring edge or earlier bridge, a
vertex on it included; an ear is refused if any other vertex lies in
its closed triangle. The output is certified before it is returned --
every triangle strictly counter-clockwise, every ring edge used once
from inside, every other edge once in each direction -- and refused
with `Degenerate` otherwise. Rings may come either way round; every
vertex is now a triangle corner, `outer ++ holes` unchanged. Ring sets
that bound no polygon with holes are refused with `InvalidInput`
naming the ring: a non-finite or repeated vertex, a ring folding back
on or crossing itself, holes overlapping or touching each other or the
outer ring (including at one vertex, which earcut accepted and which
extrudes to a non-manifold edge), a hole outside the outer ring or
inside another hole. The crate no longer depends on `earcut`. Tests
(`tests/profile_holes.rs`): holes side by side, stacked, three on one
ray, a hole vertex exactly on another's ray, five holes 1/1024 from the
outer ring, and three flattened round holes in a row each triangulate
with n + 2h - 2 counter-clockwise triangles, every ring edge once and
every other edge twinned, cover exactly outer minus holes, and extrude
to a closed, outward solid of volume area x depth; so do a triangle
and an L with a straight or reflex vertex, from every starting vertex,
as outer ring and as hole, a vertex in line with another hole's side
just past its end, and 400 seeded
random layouts of rectangles, triangles, L shapes, diamonds and
rectangles with collinear midpoints on a quarter grid (compared with
`==`) and 200 off-grid layouts of regular 3- to 40-gons; each invalid
ring set above is refused by name; the certificate refuses earcut's
output for the issue's profile. Mutation probe:
`scripts/probe_ring_triangulation_mutants.py` (31/31 killed).
## [0.3.14] - 2026-10-04
### Fixed
- **Parametric profiles with decimal sizes lower at `Tolerance::ZERO`
(#250).** `contour_lower::contour_to_arc_ring` demanded bit-equal
segment joints at `Tolerance::ZERO`, which a `Line2`/`Circle2` contour
cannot give for non-dyadic sizes: a line ends at a rounded
`origin + direction`, an arc at the `cos`/`sin` of its sweep. So every
I section with IPE or HEA sizes (sharp or with root fillets), and the
other families with decimal sizes, was refused before any boolean ran
("contour segments leave a gap of 2.6e-18"). The section router already
computes each corner and tangent point once and hands it to both
segments; the ring takes one vertex per joint (the leaving segment's
start, a line's stored origin bit for bit), so it is closed by
construction. The joint check now allows the larger of the tolerance
and the rounding of the two evaluations meeting there (eight machine
epsilons of the magnitudes they are computed from); a contour open by
more is still refused at `Tolerance::ZERO`. Rings are bit-identical to
before wherever lowering succeeded, so results at a positive tolerance
are unchanged. Tests: I (IPE 300/200, HEA 200, HEB 340; sharp, root
fillets, toe radii), asymmetric I, T, U, L, Z, C, trapezium, rounded
and hollow rectangles and an annulus lower at ZERO with each line's
shared corner as its ring vertex, and extrude at ZERO to their
closed-form areas, fillet terms `(1 - pi/4) r^2` included; tapered
I/U/L/T lower and extrude at ZERO; joints far from the origin close to
their own rounding; contours open by 1e-13 or 1e-9 are refused at ZERO.
In `axiolid-mesh-compile`, an IPE 300 beam (with and without fillets)
minus a round web hole clear of the fillets compiles at ZERO under
exact placements with an empty report and the closed-form volume.
Mutation probe: `scripts/probe_section_zero_tolerance_mutants.py`
(9/9 killed).
## [0.3.13] - 2026-10-03
### Fixed
- **Restored: swept disks mitre their corners again (#245).** Since 0.3.11
(#232) `pipe::swept_disk_along_pieces` refused a non-tangent joint as
"undefined"; that was a regression (0.3.11 and 0.3.12). A disk swept
round a corner between two straight segments is mitred at half angle,
as `IfcSweptDiskSolid` defines it: both tubes are cut by the plane that
bisects the two tangents, and the two pieces share one ring on it whose
every vertex lies on both exact cylinders (the outgoing frame is the
least rotation of the incoming one, which agrees with the reflection in
the mitre plane). The walls are planar trapezoids, exact faces of the
ring prism; the ring is chorded to `(c/2) cos(theta/2)` for the
sharpest mitre, because the mitre stretches the section by
`1 / cos(theta/2)`, so the exact tube stays within the chord budget
(derivation in the module notes). Hollow disks mitre their bore the same
way. The near-tangent tolerance `joint_tolerance` is unchanged. Refused
by name: a segment whose mitres reach past its length
(`L <= r |g_perp|`, `r tan(theta/2)` for one mitre: the tube would cut
through itself), a reversal, and a corner beside an arc (no ring lies
on both the cylinder's and the torus's cut).
- A disk radius equal to the fillet or bend radius is refused with its own
reason (#245): the format rule permits a fillet radius equal to the disk
radius, but the bend is then a horn torus whose inner wall pinches to a
point on the bend's axis, which no closed two-manifold mesh bounds
without meeting itself there.
## [0.3.12] - 2026-10-03
### Changed
- A `Curve2::Chain` profile segment is named in deviation reports
(`arc-length chain profile segment with an unbounded piece`) and in
contour-lowering refusals (#239); a chain the flattener certifies is
bounded by the chord budget like any other certified family.
## [0.3.11] - 2026-10-03
### Added
- `pipe::swept_disk_along_pieces`, with `pipe::PathPiece` and
`pipe::joint_tolerance` (#232): a disk, optionally hollow, swept along a
chain of straight segments and circular arcs, with every point of the
exact tube within the chord budget of the mesh. Half the budget chords
the disk; each piece is then bounded on its own by the #231 span bound
(exact along an arc, where the stations are rotations about its axis,
and zero along a segment), its end frames exact. Consecutive pieces
share one station, so the tube is watertight and wound one way; the
shared station's measured distance from the outgoing piece's own start
station is added to that piece's first span. A joint turning by more
than `2 asin(c / (8 r))` is a corner and is refused by name, as is a
gap above a quarter of the budget, a bend radius at or below the disk
radius, and a bend needing more than 4096 steps (`BudgetExceeded`). A
fillet radius rounds each corner between two segments with a tangent
arc (`IfcSweptDiskSolidPolygonal`); a fillet that overruns its
segments, a disk at or above the fillet radius, a reversal and a corner
beside an arc are refused by name. The derivation is in the module
notes.
- `profile::profile_deviation` and `ProfileDeviation` (#232): how far a
profile's exact boundary may lie from the rings `profile_rings` flattens
it to. The chord budget for every segment family the flattener certifies,
plus the largest merge of near-duplicate points, scaled by a derived
profile's stretch; any other family (a clothoid, a non-positive spline
weight) is unbounded by name.
### Changed
- Contour lowering splits a circular segment of half a turn or more into
equal sub-arcs below half a turn instead of refusing it (#228, ADR 0053
amended): an IFC arch is commonly one semicircle. The split vertices are
the circle evaluated at their parameters, as a segment's ends are. Only a
segment sweeping more than a whole turn is refused.
## [0.3.10] - 2026-10-02
### Added
- `sweep::swept_disk_within`, `sweep::fixed_reference_sweep_within` and
`sweep::surface_curve_sweep_within`, with `sweep::SampledPath` (#231):
sweeps that take a directrix sampler instead of fixed samples, and keep
every point of the surface their section traces along a smooth
directrix within a chord budget. Each span is bounded by the
directrix's sagitta, plus the section's extra sagitta at its furthest
point from the directrix, plus the wall quads' twist; the directrix is
resampled at half the budget until every span fits. Along a circular
arc this is exact (the stations are rotations about the arc's axis);
for other smooth curves it is the second-order estimate. A path with
end tangents places its end sections square to the curve. A path
without them (a line, a polyline, a composite) is swept as given.
### Fixed
- `revolve::revolve` and `sweep::tapered_revolve` bound the distance from
every point of the surface their rings sweep to the mesh by
`tolerance.linear()` (#231). The step round the axis used to bound the
sagitta alone, which misses the twist of the walls when the axis leaves
the profile's plane or the section tapers: the step now also covers
each wall quad's distance from its two triangles (a proved bound, zero
for the planar trapezoids of an ordinary revolution), and a tapered
turn's spiral is bounded by its own curvature. The module notes carry
the derivation, including why a chorded profile and a chorded turn
each need their own share of a surface budget. A budget beyond 4096
steps is refused with `BudgetExceeded` instead of met by fewer steps.
## [0.3.9] - 2026-10-02
### Added
- Exact swept disks (#223). `swept_disk_exact::swept_disk_along_line_exact`
sweeps a disk, optionally with a bore, along a straight segment into a
capped cylinder; `swept_disk_along_arc_exact` sweeps it along a circular
arc into a capped torus wedge, or a whole torus (with a toroidal bore for
a hollow disk) for a full turn. Both are the exact extrusion and
revolution of the disk placed with `ExactBRep::transformed`. Volumes
match `pi r^2 L` and Pappus, every solid audits clean, and certified
distances under tilted placements match their closed forms. Refused by
name: a disk reaching the arc's axis, an arc beyond a full turn; invalid
input: a non-positive radius, a bore not strictly inside the disk, a zero
span or segment, a non-orthonormal arc frame.
### Fixed
- `revolve::revolve` (the mesh path) no longer builds an inside-out mesh
for a negative sweep angle (#221). The station order it winds its walls
from gave a positive signed volume only for a positive angle; since
`angle` and `axis_direction` only matter through their product (the
axis-angle rotation vector), a sign-of-`angle` check alone could not be
made consistent with a sign flip in the axis direction too. The mesh's
orientation is now settled from its own computed volume instead, so it
matches `revolve_profile_exact`'s positive sign for either sign of angle
and either axis direction. `tests/revolve_partial.rs` compared only
`|volume|` between the two paths because of this; it now compares the
signed volumes.
- `sweep::tapered_revolve` shared the same unconditional station reversal
as `revolve::revolve` and so built the same inside-out mesh for a
negative sweep angle (#221). Fixed the same way: orientation is settled
from the built mesh's own signed volume rather than guessed from the
station order.
## [0.3.8] - 2026-10-02
### Added
- Exact partial-turn revolution (#172). `revolve_profile_exact` now builds
a revolution through `0 < |angle| < 2 pi` instead of refusing it: every
profile the full turn revolves (rectangles, circles, sections, contours
with arcs and holes, derived, centre-line and composite profiles) becomes
a closed exact B-rep whose walls are cylinder, cone, plane and torus
patches trimmed to the swept angle, capped at both ends by the profile
itself; each hole is a tunnel between the caps. Unlike the full turn, a
partial turn may touch the axis: a vertex on it sweeps no arc (a cone
closes at its apex, a planar wall becomes a sector) and a straight
segment on it sweeps no wall, its edge shared by the two caps. The angle
follows the right-hand rule about the axis direction, as the mesh path
does; a section on either side of the axis is accepted. The new
`revolve_partial::revolve_section_partial` builds one lowered section.
Volume (`theta R A`) and area (`theta integral(r ds) + 2 A`) match
Pappus at 90, 180, 270 degrees and small angles
(`tests/revolve_partial.rs`); `scripts/probe_partial_revolve_mutants.py`
lists the faults the tests must catch.
### Changed
- `revolve_profile_exact` refuses a turn beyond a full turn by name
(`"exact revolution beyond a full turn"`) and a zero angle as invalid
input; both were previously reported as the partial-turn refusal, which
no longer exists. A section crossing the axis, an arc whose circle
reaches the axis, and an ellipse remain refused by name.
## [0.3.7] - 2026-09-30
### Fixed
- `boolean_polyhedra_exact` completes long chains of grid-aligned
subtraction (#199). The depth-2 Menger sponge, 147 differences from a
unit cube, refused at subtraction 82 with "every probe direction met a
vertex or edge exactly"; it now finishes closed, with no face enclosing
zero area, and with exactly the volume of its inputs. Two causes, both
fixed at the root rather than by dropping fragments:
- A split point is now the double nearest the exact crossing, not the
result of `a + (b - a) * t` rounded twice. The old formula cut the
plane `z = 1/3` at `0.33333333333333326`, so two splits of one exact
point landed ULPs apart and a later split through the pair emitted a
ring enclosing no area. Correct rounding depends on the exact point
alone, and a crossing that is a double comes back exactly.
- A fragment one ULP wide has no double strictly inside it, so its f64
centroid rounded onto its own edge and was classified as the wrong
point, leaving a hole in the shell. Such fragments are now classified
at an exact dyadic interior point, with every predicate evaluated
exactly. A fragment that encloses no area at all is refused by name
("a split fragment encloses no area") instead of failing later in ray
classification.
- Coplanar contact on slanted planes. A fragment was classified at its f64
centroid even when rounding put that point off the fragment's plane, so
a face lying in the other solid's surface read as inside or outside it:
a slanted tetrahedron united with itself came back with four faces whose
windings disagreed, a silently broken solid. The classification point
must now be certified in the fragment's plane (else the exact probe is
used), and whether coplanar normals agree is an exact sign rather than an
f64 dot product.
- The contact-matrix thin-overlap sweep (#200) passes at every overlap down
to 1e-15: union, intersection and difference are closed and measure the
exact slab. The boolean was closed all along; the test measured it with
the 1 um `Tolerance::METRE`, which calls a real 1e-12-wide face
degenerate, drops it and reports the gap as a hole. It now measures with
`Tolerance::ZERO`, and the assertions are relative to the slab.
## [0.3.6] - 2026-09-28
### Changed
- The crates.io page is this crate's own `README.md`, with links to its
API documentation, its reference page and the source (ADR 0078).
## [0.3.5] - 2026-09-28
### Fixed
- Structural sections mesh (#193): `profile_rings` flattens every
`Profile::Section` family -- I, asymmetric I, L, T, U, C, Z, trapezium --
from the exact contour `section_contour` builds, fillets and toe radii
chorded within the budget, straight edges exact. It refused them with
`Unsupported { ProfileTriangulation }` before. Rectangles with corner
radii mesh with them, through `rectangle_contour`: the mesh path used to
drop the radii and mesh a sharp box.
- Circles are chorded from half a step off the axes, not from angle 0
(#194): the same chords, turned, so a chord's middle, inside the circle,
sits at every quarter turn. A circular void tangent to a face along an
axis direction, as openings are, leaves a sliver of material under it
instead of a chord point on the face, which pinched the solid.
## [0.3.4] - 2026-09-27
### Added
- `bounding::minimum_enclosing_sphere` (#118): the least sphere holding a
point set, by Welzl's algorithm with exact in/out decisions (diametral,
least sphere through three points, circumsphere through four), the
centre enclosed from exact dyadic values and the radius rounded up, with
`SphereEvidence::error` bounding centre offset and radius excess.
- `bounding::oriented_bounding_box` (#118): a box holding every point,
exactly (`|(p - centre) . axes[i]| <= half_extents[i]` for the returned
doubles), never larger than the axis-aligned box. Tries the axis-aligned
box, the principal axes, and each world axis, principal axis and exact
hull face normal with the exact minimum-area rectangle across it. It does
not claim the global minimum volume.
- Depends on `axiolid-exact` for the exact decisions.
## [0.3.3] - 2026-09-27
### Added
- Exact full-turn revolution of circles, hollow circles, hollow rectangles
and every section with holes (#111). Each hole revolves on its own and
joins the solid as a void shell. Verified by Pappus on every case.
- Composite profiles are unioned by `profile_lower::composite_regions` over
one `ArcArrangement` (#111): members may carry arcs and their own
openings, a member's opening stays open unless another member fills it,
and members that do not touch become separate solids of one `ExactBRep`,
for extrusion and revolution alike. Member vertices within the linear
tolerance are welded first, so members authored to meet do meet.
- `section_lower::circle_contour`: a circle profile (and its bore) as exact
quarter-arc contours. A circle moved off the origin by a derived profile
now lowers to that contour instead of being refused.
- `clip_arc_prism_exact` (#120): an arc prism cut by a half-space whose
plane passes between its caps, the "column under a sloped roof" case.
Cylindrical walls stay `Cylinder` faces trimmed by an exact `Ellipse3`
edge with a `Sinusoid2` pcurve (ADR 0071); planar walls get sloped edges;
the cut cap is unnamed. A plane parallel to the axis is refused by name.
- `boolean_prisms_exact_solids` and `boolean_arc_prisms_exact_solids`
(#120): coaxial booleans whose result falls apart into separate pieces
return one solid per piece, ordered by lowest vertex (x, then y), each
audited on its own. An empty result is an empty list. The single-solid
functions keep refusing a disconnected result, so callers that expect
one solid are not silently handed the first piece.
### Changed
- `boolean_arc_prisms_exact` runs on the exact arc overlay (ADR 0070) and
builds results it used to refuse: a result with interior holes becomes a
solid with through-passages (#120), and a result starting above `z = 0`
is extruded from its own base height. Disconnected results go through
the `_solids` variants.
- Stepped coaxial booleans are built, not refused (#120, ADR 0072):
`boolean_prisms_exact`, `boolean_arc_prisms_exact` and their `_solids`
variants return a union of prisms with different spans, a difference
whose tool stops inside the subject (notch, counterbore, blind pocket,
slot through the middle heights) as exact solids with their ledge faces.
Walls are named after the operand edge they lie on, caps and ledges
after the operand cap that made them. A result enclosing a cavity, and
pieces touching only along an edge, are refused by name.
- `clip_arc_prism_exact` builds a plane that crosses a cap inside the
section: the part of the old cap that survives keeps its name, next to
the unnamed cut.
- `boolean_stepped` docs: the bands are the lighter alternative to the
stepped solid; their volumes are checked against it.
### Changed
- Coaxial booleans whose result encloses a cavity return one solid with a
void shell (#120) instead of refusing: `boolean_prisms_exact`,
`boolean_arc_prisms_exact` and their `_solids` variants. A cavity in a
result of several pieces is still refused by name.
### Fixed
- A sharp rectangle revolved through `revolve_rectangle` wrote its cap
holes' pcurve intervals forwards although their uses run the edge
backwards (ADR 0024), so each hole's pcurve ran against its edge: the
geometric audit put it 8 off the edge and the solid measured 289 instead
of 188.5. Found by the general boolean (#167); present before #125.
- Exact revolutions are no longer built inside out (#125).
`revolve_profile_exact` and the contour revolution put their surface
frames at `(x, y, z) = (X, Z, Y)`, which is left-handed; every loop is
built anticlockwise in its parameters, so every `Forward` face pointed
into the solid. The topological and geometric audits compare faces with
each other and passed it; `exact_properties` measured `-2 pi R A` for
every Pappus fixture. The frames are now `(X, -Z, Y)`.
## [0.3.2] - 2026-09-25
### Fixed
- `sweep::swept_disk` carries its section frame along the path by
rotation-minimising frames (double reflection) instead of one fixed axis
seeded from the first segment (#169). A pipe whose later leg ran along that
axis was refused ("sweep reference direction must not be parallel to the
directrix"), and a leg NEARLY along it projected the fixed axis to a
residue of arbitrary direction, so the ring rotated between stations and
the tube's volume collapsed with no error: in a real Revit rebar model 878
bent bars were refused and 6,644 compiled more than 0.5 % short, up to
64 %. The section is a circle, so the choice of perpendicular only rotates
it about its own axis. `fixed_reference_sweep` is unchanged: there the
reference is the author's.
## [0.3.1] - 2026-09-24
### Fixed
- `extrude` (and so `extrude_profile` and the reference mesh compiler) wound
a solid inside-out when the extrusion direction pointed below the profile
plane (`direction.z < 0`), e.g. an opening body extruded downward from its
lintel (#166). The signed volume was `-area * depth`, so any boolean using
the solid refused it as inside-out. Such a solid is now outward-oriented
with the same magnitude, for outer and hole loops alike.
- `half_space::bounded_half_space_in_frame` now places the boundary at the
authored frame's origin, projected onto the clip plane (#164). It used to
take only the frame's axes and anchor the boundary at the clip plane's
origin, so a boundary frame offset within the plane cut the wrong region
with no error: the mesh stayed closed and correctly wound. The offset along
the normal is still dropped, so the sweep starts on the clip plane and
polarity and depth are unchanged. `ReferenceMeshCompiler` passes
`BoundedHalfSpace.placement.translation` as that origin, so compiled
bounded half-spaces now honour the placement's translation as well as its
rotation.
## [0.3.0] - 2026-09-23
### Added
- Exact extrusion of rounded rectangles (`IfcRoundedRectangleProfileDef`)
and of hollow rectangles with outer and inner corner radii
(`IfcRectangleHollowProfileDef`). Each corner is an exact quarter arc that
extrudes to a cylinder wall. `section_lower::rectangle_contour` builds the
contour through the same router the structural sections use
([#111](https://github.com/axiolid/kernel/issues/111)).
- Exact full-turn revolution of a filled rounded rectangle; each corner
sweeps a torus.
- Rounded and hollow rectangles are accepted as the basis of a derived
profile, with the same similarity check every other contour goes through.
### Changed
- Invalid rectangle radii are refused instead of clamped: negative,
non-finite, wider than the half-extent, an inner radius on a filled
rectangle, and a hollow section whose corners leave no wall.