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