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;