axiolid-construct 0.3.21

Solid generation: profiles, lofts, sweeps, revolutions and half-space clipping
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
# 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.