Skip to main content

manifold_rust/
progress.rs

1// Copyright 2026 Lars Brubaker
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15// progress.rs — optional, throttled progress reporting for the long-running
16// boolean pipelines.
17//
18// This is the sibling of `cancel.rs`: both are threaded through the kernel as
19// an `Option<&_>` so that the "nobody is watching" path — every pre-existing
20// caller — is byte-for-byte the code that ran before the feature existed.
21// `None` touches no atomic and takes no lock; the branch folds out of the
22// hot loops entirely because the *decision* is made once per phase, at the
23// call site of a map, not per element.
24//
25// The C++ reference has an equivalent (`ExecutionContext`'s donePhases /
26// totalPhases / Progress()), which `cancel.rs` deliberately did not port. This
27// module is not a port of it: the C++ counts whole pipeline phases, while the
28// robust engine's phases are wildly unequal in cost, so we report a *named*
29// phase plus an intra-phase fraction instead. Nothing here can change a
30// computed value — the reporter is write-only from the kernel's point of view.
31//
32// Who reports what:
33//   robust/intersection_graph.rs  NarrowPhase, SelfIntersections,
34//                                 CandidatePoints, Registries, Arrangements
35//   robust/cells.rs               Cells (per arrangement edge)
36//   robust/mod.rs                 Winding, Assemble (phase transitions only)
37//   boolean3.rs                   ExactBoolean (one indeterminate phase; the
38//                                 exact engine's internals are not
39//                                 instrumented, so its timing stays exactly
40//                                 what it was)
41//
42// Threading model: the callback is invoked under a `Mutex`, so it is never
43// re-entered concurrently even when the `parallel` feature has rayon workers
44// driving `advance`. It *can* be invoked from a worker thread rather than the
45// caller's; consumers that need a specific thread must marshal themselves.
46
47use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
48use std::sync::Mutex;
49
50/// Coarse pipeline stages, in the order the robust engine runs them.
51///
52/// Ids are part of the FFI surface (`manifold_rs_progress_phase_name`), so new
53/// phases are appended rather than inserted.
54#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
55#[repr(u32)]
56pub enum Phase {
57    NarrowPhase = 0,
58    SelfIntersections = 1,
59    CandidatePoints = 2,
60    Registries = 3,
61    Arrangements = 4,
62    Cells = 5,
63    Winding = 6,
64    Assemble = 7,
65    /// The exact engine, reported as one indeterminate phase.
66    ExactBoolean = 8,
67}
68
69impl Phase {
70    pub const ALL: [Phase; 9] = [
71        Phase::NarrowPhase,
72        Phase::SelfIntersections,
73        Phase::CandidatePoints,
74        Phase::Registries,
75        Phase::Arrangements,
76        Phase::Cells,
77        Phase::Winding,
78        Phase::Assemble,
79        Phase::ExactBoolean,
80    ];
81
82    /// Stable display name. `&'static str` so a reporter callback never has to
83    /// allocate to forward it.
84    pub fn name(self) -> &'static str {
85        match self {
86            Phase::NarrowPhase => "narrow phase",
87            Phase::SelfIntersections => "self intersections",
88            Phase::CandidatePoints => "candidate points",
89            Phase::Registries => "registries",
90            Phase::Arrangements => "arrangements",
91            Phase::Cells => "cells",
92            Phase::Winding => "winding",
93            Phase::Assemble => "assemble",
94            Phase::ExactBoolean => "exact boolean",
95        }
96    }
97
98    pub fn id(self) -> u32 {
99        self as u32
100    }
101
102    pub fn from_id(id: u32) -> Option<Phase> {
103        Phase::ALL.get(id as usize).copied()
104    }
105}
106
107/// The kernel-facing callback: the phase entered (carry both its stable id and
108/// its display name) plus either a fraction in `[0, 1]` for a determinate bar,
109/// or `None` when the phase has no meaningful total.
110///
111/// `Send + Sync` because rayon workers may drive it under the `parallel`
112/// feature. WASM consumers whose callback is a `JsValue` (not `Send`) route
113/// through a thread-local instead of relaxing this bound — see
114/// `demo/wasm/src/progress.rs`.
115type Callback = Box<dyn Fn(Phase, Option<f64>) + Send + Sync>;
116
117/// How many callbacks a determinate phase emits, at most. Chosen so the
118/// per-item cost stays a relaxed `fetch_add` plus one compare against a cached
119/// threshold: the lock and the callback itself are amortized over
120/// `total / 100` items.
121const REPORTS_PER_PHASE: u64 = 100;
122
123/// A throttled sink for pipeline progress.
124///
125/// Pass `Some(&reporter)` to a `*_with_progress` entry point; the reporter may
126/// be shared across threads and outlive the call.
127///
128/// # Example
129/// ```
130/// use manifold_rust::progress::ProgressReporter;
131/// use manifold_rust::manifold::Manifold;
132/// use manifold_rust::linalg::Vec3;
133/// use manifold_rust::types::{BooleanEngine, OpType};
134/// use std::sync::{Arc, Mutex};
135///
136/// let seen = Arc::new(Mutex::new(Vec::new()));
137/// let sink = Arc::clone(&seen);
138/// let reporter = ProgressReporter::new(move |phase, fraction| {
139///     sink.lock().unwrap().push((phase.name(), fraction));
140/// });
141///
142/// let a = Manifold::cube(Vec3::splat(1.0), true);
143/// let b = Manifold::sphere(0.6, 16);
144/// let out = a.boolean_with_engine_and_progress(
145///     &b, OpType::Add, BooleanEngine::Robust, None, Some(&reporter),
146/// );
147/// assert!(out.volume() > 0.0);
148/// assert!(!seen.lock().unwrap().is_empty());
149/// ```
150pub struct ProgressReporter {
151    callback: Mutex<Callback>,
152    /// Current phase id, as `Phase::id()`.
153    phase: AtomicU32,
154    /// Items completed in the current phase.
155    done: AtomicU64,
156    /// Items the current phase expects; 0 means "indeterminate".
157    total: AtomicU64,
158    /// `done` value at which the next callback fires.
159    next: AtomicU64,
160    /// Items between callbacks.
161    step: AtomicU64,
162}
163
164impl std::fmt::Debug for ProgressReporter {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        f.debug_struct("ProgressReporter")
167            .field("phase", &Phase::from_id(self.phase.load(Ordering::Relaxed)))
168            .field("done", &self.done.load(Ordering::Relaxed))
169            .field("total", &self.total.load(Ordering::Relaxed))
170            .finish()
171    }
172}
173
174impl ProgressReporter {
175    pub fn new<F>(callback: F) -> Self
176    where
177        F: Fn(Phase, Option<f64>) + Send + Sync + 'static,
178    {
179        Self {
180            callback: Mutex::new(Box::new(callback)),
181            phase: AtomicU32::new(Phase::NarrowPhase.id()),
182            done: AtomicU64::new(0),
183            total: AtomicU64::new(0),
184            next: AtomicU64::new(u64::MAX),
185            step: AtomicU64::new(u64::MAX),
186        }
187    }
188
189    /// Enter `phase`, expecting `total` work items (`0` = no total known, which
190    /// reports as an indeterminate phase). Always emits a callback, so a phase
191    /// transition is never throttled away.
192    pub fn begin_phase(&self, phase: Phase, total: u64) {
193        let step = (total / REPORTS_PER_PHASE).max(1);
194        self.phase.store(phase.id(), Ordering::Relaxed);
195        self.total.store(total, Ordering::Relaxed);
196        self.done.store(0, Ordering::Relaxed);
197        self.step.store(step, Ordering::Relaxed);
198        self.next
199            .store(if total == 0 { u64::MAX } else { step }, Ordering::Relaxed);
200        self.emit(phase, if total == 0 { None } else { Some(0.0) });
201    }
202
203    /// Record `n` completed work items in the current phase, emitting a
204    /// callback only when the throttle threshold is crossed.
205    ///
206    /// Safe to call from several threads at once; the counter is atomic and the
207    /// callback is serialized. Under contention two threads can both cross the
208    /// threshold and both report, which is harmless — this is a UI hint, not a
209    /// ledger.
210    #[inline]
211    pub fn advance(&self, n: u64) {
212        let done = self.done.fetch_add(n, Ordering::Relaxed) + n;
213        if done < self.next.load(Ordering::Relaxed) {
214            return;
215        }
216        self.report_at(done);
217    }
218
219    /// Cold half of [`advance`], kept out of line so the common case is a
220    /// fetch-add and a compare.
221    #[cold]
222    fn report_at(&self, done: u64) {
223        let step = self.step.load(Ordering::Relaxed);
224        self.next.store(done.saturating_add(step), Ordering::Relaxed);
225        let total = self.total.load(Ordering::Relaxed);
226        let Some(phase) = Phase::from_id(self.phase.load(Ordering::Relaxed)) else {
227            return;
228        };
229        let fraction = if total == 0 {
230            None
231        } else {
232            Some((done as f64 / total as f64).clamp(0.0, 1.0))
233        };
234        self.emit(phase, fraction);
235    }
236
237    /// Invoke the callback. A poisoned mutex (a previous callback panicked) is
238    /// deliberately ignored rather than propagated: a broken progress sink must
239    /// not take down a geometry operation.
240    fn emit(&self, phase: Phase, fraction: Option<f64>) {
241        if let Ok(cb) = self.callback.lock() {
242            cb(phase, fraction);
243        }
244    }
245}
246
247/// `Option`-aware [`ProgressReporter::begin_phase`], mirroring how
248/// [`crate::cancel::is_cancelled`] handles the absent case.
249#[inline]
250pub fn begin_phase(progress: Option<&ProgressReporter>, phase: Phase, total: u64) {
251    if let Some(p) = progress {
252        p.begin_phase(phase, total);
253    }
254}
255
256/// [`crate::par::maybe_par_map_ct`] that also counts completed items into
257/// `progress`.
258///
259/// With `progress == None` this *is* `maybe_par_map_ct` — the same closure, no
260/// wrapper — so the uninstrumented path keeps its exact codegen. With a
261/// reporter the only added work per item is one relaxed `fetch_add`; results
262/// are still collected in index order, so the output is bit-identical either
263/// way.
264#[cfg(feature = "parallel")]
265pub fn maybe_par_map_ct_progress<T, F>(
266    n: usize,
267    threshold: usize,
268    token: Option<&crate::cancel::CancelToken>,
269    progress: Option<&ProgressReporter>,
270    f: F,
271) -> Option<Vec<T>>
272where
273    T: Send,
274    F: Fn(usize) -> T + Sync + Send,
275{
276    match progress {
277        None => crate::par::maybe_par_map_ct(n, threshold, token, f),
278        Some(p) => crate::par::maybe_par_map_ct(n, threshold, token, |i| {
279            let out = f(i);
280            p.advance(1);
281            out
282        }),
283    }
284}
285
286/// Sequential fallback: identical output to the parallel version.
287#[cfg(not(feature = "parallel"))]
288pub fn maybe_par_map_ct_progress<T, F>(
289    n: usize,
290    threshold: usize,
291    token: Option<&crate::cancel::CancelToken>,
292    progress: Option<&ProgressReporter>,
293    f: F,
294) -> Option<Vec<T>>
295where
296    F: Fn(usize) -> T,
297{
298    match progress {
299        None => crate::par::maybe_par_map_ct(n, threshold, token, f),
300        Some(p) => crate::par::maybe_par_map_ct(n, threshold, token, |i| {
301            let out = f(i);
302            p.advance(1);
303            out
304        }),
305    }
306}
307
308#[cfg(test)]
309#[path = "progress_tests.rs"]
310mod tests;