Skip to main content

axiolid_reference/
primitive.rs

1//! Tessellation of CSG primitives into closed solids.
2//!
3//! Every variant is analytic, so each is emitted directly. A block has no
4//! curvature to sample; a sphere's is exactly known. The chord budget still
5//! decides the radial segment count so a primitive and a swept face of the
6//! same radius agree on what a tolerance means.
7
8use axiolid_contracts::{GeomError, GeomResult};
9use axiolid_core::{Point3, Scalar, Tolerance};
10use axiolid_mesh::TriMesh;
11use axiolid_primitive::Primitive;
12
13/// Radial segments needed for `radius` under a chord budget.
14///
15/// Same sagitta rule the curve flattener uses: r(1 - cos(pi/n)) <= tol.
16/// Clamped low so a degenerate tolerance cannot ask for an unbounded mesh.
17fn segments(radius: Scalar, tolerance: Scalar) -> usize {
18    if !(radius.is_finite() && radius > 0.0 && tolerance.is_finite() && tolerance > 0.0) {
19        return 3;
20    }
21    let ratio = 1.0 - (tolerance / radius).min(1.0);
22    let n = (core::f64::consts::PI / ratio.acos().max(1e-9)).ceil();
23    (n as usize).clamp(3, 4096)
24}
25
26/// [`segments`] for one direction of a curved surface, refused rather
27/// than clamped when the budget needs more than the clamp allows: a
28/// clamped count would leave the surface outside the bound the caller
29/// asked for (#231, #232).
30fn segments_within(radius: Scalar, tolerance: Scalar) -> GeomResult<usize> {
31    if !(tolerance.is_finite() && tolerance > 0.0) {
32        return Err(GeomError::InvalidInput(format!(
33            "chord budget must be positive and finite, got {tolerance}"
34        )));
35    }
36    let n = segments(radius, tolerance);
37    if n == 4096 && radius * (1.0 - (core::f64::consts::PI / 4096.0).cos()) > tolerance {
38        return Err(GeomError::BudgetExceeded {
39            resource: "primitive surface segments",
40        });
41    }
42    Ok(n)
43}
44
45/// Tessellate one CSG primitive into a closed, outward-wound solid.
46///
47/// A sphere and a torus are doubly curved: every point of the exact
48/// surface lies within `tolerance.linear()` of the mesh (#231). Each is the
49/// revolution of an inscribed polygon (a meridian polygon, a tube circle
50/// polygon) about its axis, which lies in the polygon's plane. A point of
51/// the exact surface is the rotation of a point within the polygon's
52/// sagitta `s_p` of the polygon, and a point of the revolved polygon is on
53/// an arc of radius at most `rho_max` (the widest vertex) whose chord lies
54/// in a planar trapezoid of the mesh, so within `rho_max (1 - cos(h/2))`
55/// of it for a step `h` round the axis. Each direction gets half the
56/// budget, so the two add to at most the whole. A cylinder and a cone are
57/// curved one way only, so their ring's sagitta is the whole deviation and
58/// gets the whole budget (#232). Any of the four that needs more than 4096
59/// segments is refused with `BudgetExceeded`, never clamped.
60///
61/// Outward winding is not decoration: `axiolid-mesh-boolean-boolmesh` and the clash
62/// containment test both read signed volume, and an inverted primitive
63/// silently produces negative volume and wrong verdicts.
64pub fn tessellate_primitive(primitive: &Primitive, tolerance: Tolerance) -> GeomResult<TriMesh> {
65    let tol = tolerance.linear();
66    match primitive {
67        Primitive::Block { x, y, z } => block(*x, *y, *z),
68        Primitive::Sphere { radius } => sphere(*radius, tol),
69        Primitive::Cylinder { radius, height } => cylinder(*radius, *height, tol),
70        Primitive::Cone { radius, height } => cone(*radius, *height, tol),
71        Primitive::Pyramid { x, y, height } => pyramid(*x, *y, *height),
72        Primitive::Torus {
73            major_radius,
74            minor_radius,
75        } => torus(*major_radius, *minor_radius, tol),
76        Primitive::Wedge {
77            x,
78            y,
79            height,
80            top_x_min,
81            top_x_max,
82            top_y_min,
83            top_y_max,
84        } => wedge(
85            [*x, *y, *height],
86            [*top_x_min, *top_x_max],
87            [*top_y_min, *top_y_max],
88        ),
89        // The enum is non_exhaustive: a new family is unsupported, never
90        // silently approximated by the nearest one.
91        _ => Err(GeomError::Unsupported {
92            backend: crate::ScalarBoolean::ID,
93            operation: axiolid_contracts::Operation::Tessellation,
94        }),
95    }
96}
97
98/// Validate a positive finite extent, naming the offender.
99fn positive(value: Scalar, what: &str) -> GeomResult<Scalar> {
100    if !value.is_finite() || value <= 0.0 {
101        return Err(GeomError::InvalidInput(format!(
102            "{what} must be positive and finite, got {value}"
103        )));
104    }
105    Ok(value)
106}
107
108/// Axis-aligned block centred on the local origin.
109fn block(x: Scalar, y: Scalar, z: Scalar) -> GeomResult<TriMesh> {
110    let (hx, hy, hz) = (
111        positive(x, "block x")? / 2.0,
112        positive(y, "block y")? / 2.0,
113        positive(z, "block z")? / 2.0,
114    );
115    let p = vec![
116        Point3::new(-hx, -hy, -hz),
117        Point3::new(hx, -hy, -hz),
118        Point3::new(hx, hy, -hz),
119        Point3::new(-hx, hy, -hz),
120        Point3::new(-hx, -hy, hz),
121        Point3::new(hx, -hy, hz),
122        Point3::new(hx, hy, hz),
123        Point3::new(-hx, hy, hz),
124    ];
125    // Outward winding, verified by the positive-volume test rather than by
126    // reading the index list.
127    let i = vec![
128        0, 2, 1, 0, 3, 2, 4, 5, 6, 4, 6, 7, 0, 1, 5, 0, 5, 4, 1, 2, 6, 1, 6, 5, 2, 3, 7, 2, 7, 6,
129        3, 0, 4, 3, 4, 7,
130    ];
131    Ok(TriMesh::new(p, i))
132}
133
134/// Rectangular pyramid: base on z = 0, apex on +z.
135fn pyramid(x: Scalar, y: Scalar, height: Scalar) -> GeomResult<TriMesh> {
136    let (hx, hy) = (
137        positive(x, "pyramid x")? / 2.0,
138        positive(y, "pyramid y")? / 2.0,
139    );
140    let h = positive(height, "pyramid height")?;
141    let p = vec![
142        Point3::new(-hx, -hy, 0.0),
143        Point3::new(hx, -hy, 0.0),
144        Point3::new(hx, hy, 0.0),
145        Point3::new(-hx, hy, 0.0),
146        Point3::new(0.0, 0.0, h),
147    ];
148    let i = vec![0, 2, 1, 0, 3, 2, 0, 1, 4, 1, 2, 4, 2, 3, 4, 3, 0, 4];
149    Ok(TriMesh::new(p, i))
150}
151
152/// Wedge: base `[0, x] x [0, y]` at z = 0, top `[x0, x1] x [y0, y1]` at
153/// z = `height`.
154///
155/// Every face is planar: the x sides join edges parallel to y, the y sides
156/// edges parallel to x. A top collapsed to a segment or a point shares
157/// vertices, so the faces that lose area are dropped and the rest meet at
158/// the shared vertices rather than along zero-length edges.
159fn wedge(
160    [x, y, height]: [Scalar; 3],
161    [x0, x1]: [Scalar; 2],
162    [y0, y1]: [Scalar; 2],
163) -> GeomResult<TriMesh> {
164    let (x, y, h) = (
165        positive(x, "wedge x")?,
166        positive(y, "wedge y")?,
167        positive(height, "wedge height")?,
168    );
169    for (value, what) in [
170        (x0, "wedge top x min"),
171        (x1, "wedge top x max"),
172        (y0, "wedge top y min"),
173        (y1, "wedge top y max"),
174    ] {
175        if !value.is_finite() {
176            return Err(GeomError::InvalidInput(format!(
177                "{what} must be finite, got {value}"
178            )));
179        }
180    }
181    if x0 > x1 || y0 > y1 {
182        return Err(GeomError::InvalidInput(format!(
183            "wedge top must have min <= max, got x {x0}..{x1}, y {y0}..{y1}"
184        )));
185    }
186    let corners = [
187        Point3::new(0.0, 0.0, 0.0),
188        Point3::new(x, 0.0, 0.0),
189        Point3::new(x, y, 0.0),
190        Point3::new(0.0, y, 0.0),
191        Point3::new(x0, y0, h),
192        Point3::new(x1, y0, h),
193        Point3::new(x1, y1, h),
194        Point3::new(x0, y1, h),
195    ];
196    // Outward faces over the corners above: bottom, top, then the sides at
197    // y = 0, x = x, y = y and x = 0.
198    const FACES: [[usize; 4]; 6] = [
199        [0, 3, 2, 1],
200        [4, 5, 6, 7],
201        [0, 1, 5, 4],
202        [1, 2, 6, 5],
203        [2, 3, 7, 6],
204        [3, 0, 4, 7],
205    ];
206    let mut p: Vec<Point3> = Vec::with_capacity(8);
207    let index: Vec<u32> = corners
208        .iter()
209        .map(|&c| match p.iter().position(|&q| q == c) {
210            Some(k) => k as u32,
211            None => {
212                p.push(c);
213                (p.len() - 1) as u32
214            }
215        })
216        .collect();
217    let mut i = Vec::with_capacity(36);
218    for face in FACES {
219        let mut ring: Vec<u32> = Vec::with_capacity(4);
220        for corner in face {
221            let v = index[corner];
222            if ring.last() != Some(&v) {
223                ring.push(v);
224            }
225        }
226        if ring.len() > 1 && ring.first() == ring.last() {
227            ring.pop();
228        }
229        if ring.len() < 3 {
230            continue;
231        }
232        // Each face is convex, so a fan triangulates it.
233        for k in 1..ring.len() - 1 {
234            i.extend([ring[0], ring[k], ring[k + 1]]);
235        }
236    }
237    Ok(TriMesh::new(p, i))
238}
239
240/// Ring torus about +z, tube centre circle of radius `major` in z = 0.
241///
242/// A grid of `n` steps round the axis by `m` round the tube, each sized by
243/// the same chord rule as the other curved primitives at HALF the budget
244/// (#231): `n` for the outer equator, the largest circle round the axis.
245/// Each grid cell is a planar trapezoid (its two edges round the axis are
246/// parallel chords), split in two, so the tube polygon's sagitta and the
247/// equator's are the only deviations and their sum fits the budget.
248fn torus(major: Scalar, minor: Scalar, tol: Scalar) -> GeomResult<TriMesh> {
249    let big = positive(major, "torus major radius")?;
250    let r = positive(minor, "torus minor radius")?;
251    if r >= big {
252        let kind = if r == big {
253            "a horn torus (minor radius equal to major)"
254        } else {
255            "a spindle torus (minor radius above major)"
256        };
257        return Err(GeomError::InvalidInput(format!(
258            "torus minor radius {r} must be below major radius {big}: {kind} \
259             does not bound a two-manifold solid"
260        )));
261    }
262    let n = segments_within(big + r, 0.5 * tol)?;
263    let m = segments_within(r, 0.5 * tol)?;
264    let mut p = Vec::with_capacity(n * m);
265    for i in 0..n {
266        let theta = core::f64::consts::TAU * (i as Scalar) / (n as Scalar);
267        for j in 0..m {
268            let phi = core::f64::consts::TAU * (j as Scalar) / (m as Scalar);
269            let rho = big + r * phi.cos();
270            p.push(Point3::new(
271                rho * theta.cos(),
272                rho * theta.sin(),
273                r * phi.sin(),
274            ));
275        }
276    }
277    let at = |i: usize, j: usize| ((i % n) * m + (j % m)) as u32;
278    let mut idx = Vec::with_capacity(n * m * 6);
279    for i in 0..n {
280        for j in 0..m {
281            // Round the axis, then round the tube: outward, since the
282            // axis tangent crossed with the tube tangent points away from
283            // the tube's centre.
284            let (a, b, c, d) = (at(i, j), at(i + 1, j), at(i + 1, j + 1), at(i, j + 1));
285            idx.extend([a, b, c]);
286            idx.extend([a, c, d]);
287        }
288    }
289    Ok(TriMesh::new(p, idx))
290}
291
292/// A ring of `n` points at `radius`, height `z`.
293fn ring(radius: Scalar, z: Scalar, n: usize) -> Vec<Point3> {
294    (0..n)
295        .map(|k| {
296            let a = core::f64::consts::TAU * (k as Scalar) / (n as Scalar);
297            Point3::new(radius * a.cos(), radius * a.sin(), z)
298        })
299        .collect()
300}
301
302/// Cylinder along +z, base on z = 0.
303///
304/// Every point of the exact surface lies within `tol` of the mesh (#232).
305/// The side is ruled: a point at angle `a` between two ring vertices, at
306/// height `z`, lies on the generator through `a`, whose foot in the
307/// plane of the wall rectangle between the two vertices' generators is at
308/// the same height and at most the sagitta `r (1 - cos(pi/n))` away, and
309/// the rectangle is the mesh's own two triangles. A cap point outside the
310/// inscribed polygon lies in a circular segment, within the sagitta of its
311/// chord. `n` is the least count whose sagitta fits; a budget beyond 4096
312/// segments is refused rather than clamped.
313fn cylinder(radius: Scalar, height: Scalar, tol: Scalar) -> GeomResult<TriMesh> {
314    let r = positive(radius, "cylinder radius")?;
315    let h = positive(height, "cylinder height")?;
316    let n = segments_within(r, tol)?;
317    let mut p = ring(r, 0.0, n);
318    p.extend(ring(r, h, n));
319    p.push(Point3::new(0.0, 0.0, 0.0));
320    p.push(Point3::new(0.0, 0.0, h));
321    let (bc, tc) = (2 * n, 2 * n + 1);
322    let mut i = Vec::with_capacity(n * 12);
323    for k in 0..n {
324        let (a, b) = (k, (k + 1) % n);
325        // Side quad, then the two caps. The base fan is wound opposite to
326        // the top so both face away from the enclosed volume.
327        i.extend([a as u32, b as u32, (b + n) as u32]);
328        i.extend([a as u32, (b + n) as u32, (a + n) as u32]);
329        i.extend([bc as u32, b as u32, a as u32]);
330        i.extend([tc as u32, (a + n) as u32, (b + n) as u32]);
331    }
332    Ok(TriMesh::new(p, i))
333}
334
335/// Cone along +z: base ring on z = 0, apex at height.
336///
337/// Bounded as the cylinder (#232): the horizontal section of the cone at
338/// height `z` is a circle of radius `r (1 - z/h)` and that of each side
339/// triangle the chord of it between the same two angles, so a point of the
340/// cone is within `r (1 - z/h)(1 - cos(pi/n))`, at most the base ring's
341/// sagitta, of a side triangle. The base cap is the cylinder's.
342fn cone(radius: Scalar, height: Scalar, tol: Scalar) -> GeomResult<TriMesh> {
343    let r = positive(radius, "cone radius")?;
344    let h = positive(height, "cone height")?;
345    let n = segments_within(r, tol)?;
346    let mut p = ring(r, 0.0, n);
347    p.push(Point3::new(0.0, 0.0, 0.0));
348    p.push(Point3::new(0.0, 0.0, h));
349    let (base, apex) = (n, n + 1);
350    let mut i = Vec::with_capacity(n * 6);
351    for k in 0..n {
352        let (a, b) = (k as u32, ((k + 1) % n) as u32);
353        i.extend([base as u32, b, a]);
354        i.extend([a, b, apex as u32]);
355    }
356    Ok(TriMesh::new(p, i))
357}
358
359/// Sphere centred on the local origin, as a UV mesh.
360///
361/// Half the budget goes round the axis and half down the meridian (#231).
362fn sphere(radius: Scalar, tol: Scalar) -> GeomResult<TriMesh> {
363    let r = positive(radius, "sphere radius")?;
364    let n = segments_within(r, 0.5 * tol)?;
365    // Half as many stacks as segments: the polar direction spans PI, not TAU,
366    // so equal counts would oversample it by 2x for the same chord error.
367    // Rounded UP: an odd `n` halved down would make the polar step longer
368    // than `TAU / n` and its chords deeper than the budget.
369    let stacks = n.div_ceil(2).max(2);
370    let mut p = Vec::with_capacity((stacks + 1) * n);
371    for i in 0..=stacks {
372        let v = core::f64::consts::PI * (i as Scalar) / (stacks as Scalar);
373        for j in 0..n {
374            let u = core::f64::consts::TAU * (j as Scalar) / (n as Scalar);
375            p.push(Point3::new(
376                r * v.sin() * u.cos(),
377                r * v.sin() * u.sin(),
378                r * v.cos(),
379            ));
380        }
381    }
382    let mut idx = Vec::with_capacity(stacks * n * 6);
383    for i in 0..stacks {
384        for j in 0..n {
385            let jn = (j + 1) % n;
386            // A pole row's n entries are the same point, so they must map
387            // to ONE index or no triangle around the pole shares an edge.
388            let row = |r: usize, k: usize| -> u32 {
389                if r == 0 || r == stacks {
390                    (r * n) as u32
391                } else {
392                    (r * n + k) as u32
393                }
394            };
395            let a = row(i, j);
396            let b = row(i, jn);
397            let c = row(i + 1, j);
398            let d = row(i + 1, jn);
399            // Pole rows collapse to a point; skip the degenerate half.
400            if i > 0 {
401                idx.extend([a, c, b]);
402            }
403            if i + 1 < stacks {
404                idx.extend([b, c, d]);
405            }
406        }
407    }
408    Ok(TriMesh::new(p, idx))
409}