Skip to main content

fast_mesh_ops/
deform.rs

1//! Brush deformation: moving vertices along their normals under a falloff.
2//!
3//! [`inflate`] pushes the vertices under a [`Brush`] along their smooth
4//! normals ([`compute_vertex_normals`](fast_mesh::compute_vertex_normals)),
5//! scaled by the brush [`strength`](Brush::strength) and the
6//! [`falloff`](Brush::falloff) weight of their distance from the brush
7//! center. A negative strength pulls instead of pushing.
8//!
9//! [`inflate`], [`smooth`] and [`grab`] cost O(mesh) per call: they scan every
10//! vertex, and the first two compute over the whole mesh. [`inflate_region`],
11//! [`smooth_region`] and [`grab_region`] move the same vertices to the same
12//! positions, bit for bit, from a candidate list and the mesh's incidence
13//! index, so that their cost follows the brush region. `Scene::dab` in
14//! `fast-mesh-scene` finds the candidates through the BVH.
15
16use fast_mesh::compute_vertex_normals;
17use fast_mesh_store::{Incidence, Mesh, Transaction, VertexId};
18
19use crate::topology::VertexFans;
20use crate::{OpsError, Result, push, reserve};
21
22/// How a brush's effect fades from its center (`t = 0`) to its rim (`t = 1`).
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Falloff {
25    /// Linear decay: `1 - t`.
26    Linear,
27    /// Smoothstep decay: `s = 1 - t`, then `s * s * (3 - 2 * s)`.
28    Smooth,
29}
30
31/// The falloff weight at normalized distance `t` (0 at the center, 1 at the
32/// rim). Callers keep `t` below 1; at and past the rim the brush has no
33/// effect.
34pub(crate) fn falloff_value(falloff: Falloff, t: f32) -> f32 {
35    match falloff {
36        Falloff::Linear => 1.0 - t,
37        Falloff::Smooth => {
38            let s = 1.0 - t;
39            s * s * (3.0 - 2.0 * s)
40        }
41    }
42}
43
44/// Whether any live vertex is under the brush: closer than `radius` to
45/// `center`, the same membership test the region loops use.
46fn has_region_member(mesh: &Mesh, brush: &Brush) -> bool {
47    mesh.positions().iter().enumerate().any(|(v, p)| {
48        if !mesh.is_vertex_alive(v as VertexId) {
49            return false;
50        }
51        let dist = brush
52            .center
53            .iter()
54            .zip(p)
55            .map(|(c, p)| (p - c).powi(2))
56            .sum::<f32>()
57            .sqrt();
58        let t = dist / brush.radius;
59        t.is_finite() && t < 1.0
60    })
61}
62
63/// The `strength * falloff` weight of a vertex at `p`, or `None` at or past
64/// the rim: the region loops' membership test.
65fn brush_weight(brush: &Brush, p: &[f32; 3]) -> Option<f32> {
66    let dist = brush
67        .center
68        .iter()
69        .zip(p)
70        .map(|(c, p)| (p - c).powi(2))
71        .sum::<f32>()
72        .sqrt();
73    let t = dist / brush.radius;
74    (t.is_finite() && t < 1.0).then(|| brush.strength * falloff_value(brush.falloff, t))
75}
76
77/// The mesh's incidence index, which the region functions read fans from.
78fn region_index(mesh: &Mesh) -> Result<&Incidence> {
79    mesh.incidence().ok_or(OpsError::InvalidInput(
80        "region deforms need Mesh::ensure_incidence and no topology edit in the transaction",
81    ))
82}
83
84/// A deform brush: a sphere of influence with a falloff and a strength.
85#[derive(Debug, Clone, Copy, PartialEq)]
86pub struct Brush {
87    /// Center of the brush in local coordinates.
88    pub center: [f32; 3],
89    /// Radius of influence in local units; vertices at or past it are
90    /// untouched. Must be positive and finite.
91    pub radius: f32,
92    /// How the effect fades with distance from the center.
93    pub falloff: Falloff,
94    /// Displacement scale in local units; negative pulls instead of pushing.
95    /// Must be finite.
96    pub strength: f32,
97}
98
99impl Default for Brush {
100    fn default() -> Self {
101        Brush {
102            center: [0.0; 3],
103            radius: 1.0,
104            falloff: Falloff::Linear,
105            strength: 1.0,
106        }
107    }
108}
109
110/// Options for [`inflate`].
111#[derive(Debug, Clone, Copy, Default, PartialEq)]
112pub struct InflateOptions {
113    /// The brush to apply.
114    pub brush: Brush,
115}
116
117impl InflateOptions {
118    /// A builder for [`InflateOptions`]: the default brush until set.
119    pub fn builder() -> InflateOptionsBuilder {
120        InflateOptionsBuilder::default()
121    }
122}
123
124/// A builder for [`InflateOptions`], see [`InflateOptions::builder`].
125#[derive(Debug, Clone, Default)]
126pub struct InflateOptionsBuilder {
127    options: InflateOptions,
128}
129
130impl InflateOptionsBuilder {
131    /// Center of the brush in local coordinates.
132    pub fn center(mut self, center: [f32; 3]) -> Self {
133        self.options.brush.center = center;
134        self
135    }
136
137    /// Radius of influence in local units.
138    pub fn radius(mut self, radius: f32) -> Self {
139        self.options.brush.radius = radius;
140        self
141    }
142
143    /// How the effect fades with distance from the center.
144    pub fn falloff(mut self, falloff: Falloff) -> Self {
145        self.options.brush.falloff = falloff;
146        self
147    }
148
149    /// Displacement scale in local units; negative pulls instead of pushing.
150    pub fn strength(mut self, strength: f32) -> Self {
151        self.options.brush.strength = strength;
152        self
153    }
154
155    /// The options as set.
156    pub fn build(self) -> InflateOptions {
157        self.options
158    }
159}
160
161/// What [`inflate`] did.
162#[derive(Debug, Clone, Copy, Default, PartialEq)]
163pub struct InflateReport {
164    /// How many vertices moved (displacement above 0).
165    pub moved: usize,
166    /// The largest displacement applied, in local units; 0 when nothing
167    /// moved.
168    pub max_displacement: f32,
169}
170
171fn check_inflate(brush: &Brush) -> Result<()> {
172    if !brush.radius.is_finite() || brush.radius <= 0.0 {
173        return Err(OpsError::InvalidInput(
174            "inflate brush radius must be positive and finite",
175        ));
176    }
177    if brush.center.iter().any(|c| !c.is_finite()) {
178        return Err(OpsError::InvalidInput(
179            "inflate brush center must be finite",
180        ));
181    }
182    if !brush.strength.is_finite() {
183        return Err(OpsError::InvalidInput(
184            "inflate brush strength must be finite",
185        ));
186    }
187    Ok(())
188}
189
190/// Pushes the vertices under the brush along their smooth normals.
191///
192/// - Region: every live vertex closer than `radius` to `center` (brute-force
193///   distance check), each displaced by `strength * falloff * normal`, where
194///   `falloff` is the [`Falloff`] weight of its normalized distance and
195///   `normal` its smooth normal.
196/// - The move is written once, in a single `set_positions` call over the
197///   vertices whose displacement is above 0; an empty region writes nothing.
198///   History is untouched.
199///
200/// # Errors
201/// - [`OpsError::InvalidInput`] when the radius is not positive, or any brush
202///   number (center, radius, strength) is not finite.
203/// - [`OpsError::Store`] when memory runs out.
204///
205/// Nothing is written in any of these cases.
206pub fn inflate(tx: &mut Transaction, options: &InflateOptions) -> Result<InflateReport> {
207    let brush = &options.brush;
208    check_inflate(brush)?;
209    let mesh = tx.mesh();
210    let mut report = InflateReport::default();
211    if mesh.vertex_slots() == 0 {
212        return Ok(report);
213    }
214    // Members first: with no live vertex under the brush the call is a
215    // guaranteed no-op, so skip the whole-mesh temporaries below.
216    if !has_region_member(mesh, brush) {
217        return Ok(report);
218    }
219    // Slot-aligned flat positions and the alive triangles, the layout
220    // `compute_vertex_normals` takes; normals come back slot-aligned too.
221    let positions = mesh.positions();
222    let mut flat: Vec<f32> = Vec::new();
223    reserve(&mut flat, positions.len() * 3)?;
224    flat.extend(positions.iter().flatten().copied());
225    let mut indices: Vec<u32> = Vec::new();
226    reserve(&mut indices, mesh.triangle_slots() * 3)?;
227    for t in 0..mesh.triangle_slots() as u32 {
228        if let Some(tri) = mesh.triangle(t) {
229            indices.extend(tri);
230        }
231    }
232    let normals = compute_vertex_normals(&flat, &indices)
233        .map_err(|_| OpsError::InvalidInput("mesh buffers are not a valid mesh"))?;
234    let mut ids: Vec<VertexId> = Vec::new();
235    let mut moved_to: Vec<[f32; 3]> = Vec::new();
236    for (v, p) in positions.iter().enumerate() {
237        let v = v as VertexId;
238        if !mesh.is_vertex_alive(v) {
239            continue;
240        }
241        let dist = brush
242            .center
243            .iter()
244            .zip(p)
245            .map(|(c, p)| (p - c).powi(2))
246            .sum::<f32>()
247            .sqrt();
248        let t = dist / brush.radius;
249        if !t.is_finite() || t >= 1.0 {
250            continue;
251        }
252        let w = brush.strength * falloff_value(brush.falloff, t);
253        let n = [
254            normals[3 * v as usize],
255            normals[3 * v as usize + 1],
256            normals[3 * v as usize + 2],
257        ];
258        let displaced = [p[0] + w * n[0], p[1] + w * n[1], p[2] + w * n[2]];
259        let length = n.iter().map(|n| (w * n).powi(2)).sum::<f32>().sqrt();
260        if !length.is_finite() || length <= 0.0 {
261            continue;
262        }
263        push(&mut ids, v)?;
264        push(&mut moved_to, displaced)?;
265        report.max_displacement = report.max_displacement.max(length);
266    }
267    report.moved = ids.len();
268    if !ids.is_empty() {
269        tx.set_positions(&ids, &moved_to)?;
270    }
271    Ok(report)
272}
273
274/// The shrinking Taubin step: each pass moves a vertex `w * λ` of the way
275/// from its position toward its neighbors' centroid.
276pub const TAUBIN_LAMBDA: f32 = 0.33;
277
278/// The inflating Taubin step: each pass moves a vertex `w * μ` of the way
279/// from its position toward its neighbors' centroid, which with the negative
280/// `μ` pushes it back out past where the λ pass left it.
281pub const TAUBIN_MU: f32 = -0.34;
282
283/// How [`smooth`] relaxes: volume-preserving Taubin λ/μ cycles, or plain
284/// Laplacian passes that melt toward the centroid (sculpt smoothing).
285#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
286pub enum SmoothMethod {
287    /// Taubin λ/μ cycles (the default): shrink then inflate back, keeping
288    /// volume while removing noise. Converges slowly on low frequencies.
289    #[default]
290    Taubin,
291    /// Plain Laplacian passes: each pass moves the full weight toward the
292    /// centroid. Melts bumps fast, with some shrinkage.
293    Laplacian,
294}
295
296/// Options for [`smooth`].
297#[derive(Debug, Clone, Copy, Default, PartialEq)]
298pub struct SmoothOptions {
299    /// The brush to apply.
300    pub brush: Brush,
301    /// How many cycles to run; 0 moves nothing.
302    pub cycles: u32,
303    /// How each cycle relaxes.
304    pub method: SmoothMethod,
305}
306
307impl SmoothOptions {
308    /// A builder for [`SmoothOptions`]: the default brush until set, 0 cycles.
309    pub fn builder() -> SmoothOptionsBuilder {
310        SmoothOptionsBuilder::default()
311    }
312}
313
314/// A builder for [`SmoothOptions`], see [`SmoothOptions::builder`].
315#[derive(Debug, Clone, Default)]
316pub struct SmoothOptionsBuilder {
317    options: SmoothOptions,
318}
319
320impl SmoothOptionsBuilder {
321    /// Center of the brush in local coordinates.
322    pub fn center(mut self, center: [f32; 3]) -> Self {
323        self.options.brush.center = center;
324        self
325    }
326
327    /// Radius of influence in local units.
328    pub fn radius(mut self, radius: f32) -> Self {
329        self.options.brush.radius = radius;
330        self
331    }
332
333    /// How the effect fades with distance from the center.
334    pub fn falloff(mut self, falloff: Falloff) -> Self {
335        self.options.brush.falloff = falloff;
336        self
337    }
338
339    /// Displacement scale; the per-vertex weight is `strength * falloff`.
340    pub fn strength(mut self, strength: f32) -> Self {
341        self.options.brush.strength = strength;
342        self
343    }
344
345    /// How many cycles to run.
346    pub fn cycles(mut self, cycles: u32) -> Self {
347        self.options.cycles = cycles;
348        self
349    }
350
351    /// How each cycle relaxes (Taubin λ/μ by default).
352    pub fn method(mut self, method: SmoothMethod) -> Self {
353        self.options.method = method;
354        self
355    }
356
357    /// The options as set.
358    pub fn build(self) -> SmoothOptions {
359        self.options
360    }
361}
362
363/// What [`smooth`] did.
364#[derive(Debug, Clone, Copy, Default, PartialEq)]
365pub struct SmoothReport {
366    /// How many vertices moved (displacement above 0).
367    pub moved: usize,
368    /// The largest displacement applied, in local units; 0 when nothing
369    /// moved.
370    pub max_displacement: f32,
371}
372
373fn check_smooth(brush: &Brush) -> Result<()> {
374    if !brush.radius.is_finite() || brush.radius <= 0.0 {
375        return Err(OpsError::InvalidInput(
376            "smooth brush radius must be positive and finite",
377        ));
378    }
379    if brush.center.iter().any(|c| !c.is_finite()) {
380        return Err(OpsError::InvalidInput("smooth brush center must be finite"));
381    }
382    if !brush.strength.is_finite() {
383        return Err(OpsError::InvalidInput(
384            "smooth brush strength must be finite",
385        ));
386    }
387    Ok(())
388}
389
390/// Smooths the vertices under the brush, Taubin λ/μ or plain Laplacian.
391///
392/// - Region: every live vertex closer than `radius` to `center` (brute-force
393///   distance check). Vertices outside it never move but still serve as
394///   neighbor sources.
395/// - Each of the `cycles` runs one pass per method constant (λ then μ for
396///   [`SmoothMethod::Taubin` ([`TAUBIN_LAMBDA`], [`TAUBIN_MU`])), a single
397///   full pass for [`SmoothMethod::Laplacian`]); every pass moves each region
398///   vertex by `w * k * (centroid - p)`, where `w` is `strength * falloff`
399///   at its normalized distance, `k` the pass constant, and `centroid` the
400///   mean of its neighbor vertices (the deduped vertices of its fan
401///   triangles, excluding itself). Both the moved positions and the neighbor
402///   sources come from a snapshot taken before the pass.
403/// - The move is written once, in a single `set_positions` call over the
404///   vertices whose total displacement is above 0; an empty region writes
405///   nothing. History is untouched.
406///
407/// # Errors
408/// - [`OpsError::InvalidInput`] when the radius is not positive, or any brush
409///   number (center, radius, strength) is not finite.
410/// - [`OpsError::Store`] when memory runs out.
411///
412/// Nothing is written in any of these cases.
413pub fn smooth(tx: &mut Transaction, options: &SmoothOptions) -> Result<SmoothReport> {
414    let brush = &options.brush;
415    check_smooth(brush)?;
416    let report = SmoothReport::default();
417    if options.cycles == 0 {
418        return Ok(report);
419    }
420    let mesh = tx.mesh();
421    if mesh.vertex_slots() == 0 {
422        return Ok(report);
423    }
424    // Members first: with no live vertex under the brush the call is a
425    // guaranteed no-op, so skip the whole-mesh temporaries below.
426    if !has_region_member(mesh, brush) {
427        return Ok(report);
428    }
429    let fans = VertexFans::build(mesh)?;
430    let positions = mesh.positions();
431    // The region members with their `strength * falloff` weights.
432    let mut members: Vec<(VertexId, f32)> = Vec::new();
433    for (v, p) in positions.iter().enumerate() {
434        let v = v as VertexId;
435        if !mesh.is_vertex_alive(v) {
436            continue;
437        }
438        let dist = brush
439            .center
440            .iter()
441            .zip(p)
442            .map(|(c, p)| (p - c).powi(2))
443            .sum::<f32>()
444            .sqrt();
445        let t = dist / brush.radius;
446        if !t.is_finite() || t >= 1.0 {
447            continue;
448        }
449        push(
450            &mut members,
451            (v, brush.strength * falloff_value(brush.falloff, t)),
452        )?;
453    }
454    // Working positions, slot-aligned; only region members ever change.
455    let mut current: Vec<[f32; 3]> = Vec::new();
456    reserve(&mut current, positions.len())?;
457    current.extend_from_slice(positions);
458    let mut before: Vec<[f32; 3]> = Vec::new();
459    let mut adjacent: Vec<VertexId> = Vec::new();
460    let passes: &[f32] = match options.method {
461        SmoothMethod::Taubin => &[TAUBIN_LAMBDA, TAUBIN_MU],
462        SmoothMethod::Laplacian => &[1.0],
463    };
464    for _ in 0..options.cycles {
465        for &k in passes {
466            before.clear();
467            let missing = current.len().saturating_sub(before.capacity());
468            reserve(&mut before, missing)?;
469            before.extend_from_slice(&current);
470            for &(v, w) in &members {
471                adjacent.clear();
472                for &t in fans.fan(v) {
473                    if let Some(tri) = mesh.triangle(t) {
474                        for u in tri {
475                            if u != v {
476                                push(&mut adjacent, u)?;
477                            }
478                        }
479                    }
480                }
481                if adjacent.is_empty() {
482                    continue;
483                }
484                adjacent.sort_unstable();
485                adjacent.dedup();
486                let mut centroid = [0.0f32; 3];
487                for &u in &adjacent {
488                    for axis in 0..3 {
489                        centroid[axis] += before[u as usize][axis];
490                    }
491                }
492                for c in &mut centroid {
493                    *c /= adjacent.len() as f32;
494                }
495                let p = before[v as usize];
496                for axis in 0..3 {
497                    current[v as usize][axis] = p[axis] + w * k * (centroid[axis] - p[axis]);
498                }
499            }
500        }
501    }
502    let mut report = SmoothReport::default();
503    let mut ids: Vec<VertexId> = Vec::new();
504    let mut moved_to: Vec<[f32; 3]> = Vec::new();
505    for &(v, _) in &members {
506        let length = (0..3)
507            .map(|k| (current[v as usize][k] - positions[v as usize][k]).powi(2))
508            .sum::<f32>()
509            .sqrt();
510        if !length.is_finite() || length <= 0.0 {
511            continue;
512        }
513        push(&mut ids, v)?;
514        push(&mut moved_to, current[v as usize])?;
515        report.max_displacement = report.max_displacement.max(length);
516    }
517    report.moved = ids.len();
518    if !ids.is_empty() {
519        tx.set_positions(&ids, &moved_to)?;
520    }
521    Ok(report)
522}
523
524/// Options for [`grab`].
525#[derive(Debug, Clone, Copy, Default, PartialEq)]
526pub struct GrabOptions {
527    /// The brush to apply.
528    pub brush: Brush,
529    /// The drag vector in local coordinates: each region vertex moves
530    /// `strength * falloff * direction`. Must be finite; a zero vector moves
531    /// nothing.
532    pub direction: [f32; 3],
533}
534
535impl GrabOptions {
536    /// A builder for [`GrabOptions`]: the default brush until set, no drag.
537    pub fn builder() -> GrabOptionsBuilder {
538        GrabOptionsBuilder::default()
539    }
540}
541
542/// A builder for [`GrabOptions`], see [`GrabOptions::builder`].
543#[derive(Debug, Clone, Default)]
544pub struct GrabOptionsBuilder {
545    options: GrabOptions,
546}
547
548impl GrabOptionsBuilder {
549    /// Center of the brush in local coordinates.
550    pub fn center(mut self, center: [f32; 3]) -> Self {
551        self.options.brush.center = center;
552        self
553    }
554
555    /// Radius of influence in local units.
556    pub fn radius(mut self, radius: f32) -> Self {
557        self.options.brush.radius = radius;
558        self
559    }
560
561    /// How the effect fades with distance from the center.
562    pub fn falloff(mut self, falloff: Falloff) -> Self {
563        self.options.brush.falloff = falloff;
564        self
565    }
566
567    /// Displacement scale; the per-vertex offset is `strength * falloff *
568    /// direction`.
569    pub fn strength(mut self, strength: f32) -> Self {
570        self.options.brush.strength = strength;
571        self
572    }
573
574    /// The drag vector in local coordinates.
575    pub fn direction(mut self, direction: [f32; 3]) -> Self {
576        self.options.direction = direction;
577        self
578    }
579
580    /// The options as set.
581    pub fn build(self) -> GrabOptions {
582        self.options
583    }
584}
585
586/// What [`grab`] did.
587#[derive(Debug, Clone, Copy, Default, PartialEq)]
588pub struct GrabReport {
589    /// How many vertices moved (displacement above 0).
590    pub moved: usize,
591    /// The largest displacement applied, in local units; 0 when nothing
592    /// moved.
593    pub max_displacement: f32,
594}
595
596fn check_grab(options: &GrabOptions) -> Result<()> {
597    let brush = &options.brush;
598    if !brush.radius.is_finite() || brush.radius <= 0.0 {
599        return Err(OpsError::InvalidInput(
600            "grab brush radius must be positive and finite",
601        ));
602    }
603    if brush.center.iter().any(|c| !c.is_finite()) {
604        return Err(OpsError::InvalidInput("grab brush center must be finite"));
605    }
606    if !brush.strength.is_finite() {
607        return Err(OpsError::InvalidInput("grab brush strength must be finite"));
608    }
609    if options.direction.iter().any(|c| !c.is_finite()) {
610        return Err(OpsError::InvalidInput("grab direction must be finite"));
611    }
612    Ok(())
613}
614
615/// Drags the vertices under the brush along one vector.
616///
617/// - Region: every live vertex closer than `radius` to `center` (brute-force
618///   distance check), each displaced by `strength * falloff * direction`,
619///   where `falloff` is the [`Falloff`] weight of its normalized distance.
620///   Unlike [`inflate`], the direction is the caller's, not the normals'.
621/// - The move is written once, in a single `set_positions` call over the
622///   vertices whose displacement is above 0; an empty region, or a zero
623///   `direction`, writes nothing. History is untouched.
624///
625/// # Errors
626/// - [`OpsError::InvalidInput`] when the radius is not positive, or any brush
627///   number (center, radius, strength) or the direction is not finite.
628/// - [`OpsError::Store`] when memory runs out.
629///
630/// Nothing is written in any of these cases.
631pub fn grab(tx: &mut Transaction, options: &GrabOptions) -> Result<GrabReport> {
632    let brush = &options.brush;
633    check_grab(options)?;
634    let mesh = tx.mesh();
635    let mut report = GrabReport::default();
636    if mesh.vertex_slots() == 0 {
637        return Ok(report);
638    }
639    let positions = mesh.positions();
640    let mut ids: Vec<VertexId> = Vec::new();
641    let mut moved_to: Vec<[f32; 3]> = Vec::new();
642    for (v, p) in positions.iter().enumerate() {
643        let v = v as VertexId;
644        if !mesh.is_vertex_alive(v) {
645            continue;
646        }
647        let dist = brush
648            .center
649            .iter()
650            .zip(p)
651            .map(|(c, p)| (p - c).powi(2))
652            .sum::<f32>()
653            .sqrt();
654        let t = dist / brush.radius;
655        if !t.is_finite() || t >= 1.0 {
656            continue;
657        }
658        let w = brush.strength * falloff_value(brush.falloff, t);
659        let displaced = [
660            p[0] + w * options.direction[0],
661            p[1] + w * options.direction[1],
662            p[2] + w * options.direction[2],
663        ];
664        let length = options
665            .direction
666            .iter()
667            .map(|d| (w * d).powi(2))
668            .sum::<f32>()
669            .sqrt();
670        if !length.is_finite() || length <= 0.0 {
671            continue;
672        }
673        push(&mut ids, v)?;
674        push(&mut moved_to, displaced)?;
675        report.max_displacement = report.max_displacement.max(length);
676    }
677    report.moved = ids.len();
678    if !ids.is_empty() {
679        tx.set_positions(&ids, &moved_to)?;
680    }
681    Ok(report)
682}
683
684/// Writes `moved_to` over `ids`, once, if any vertex moved.
685fn write_moves(tx: &mut Transaction, ids: &[VertexId], moved_to: &[[f32; 3]]) -> Result<()> {
686    if !ids.is_empty() {
687        tx.set_positions(ids, moved_to)?;
688    }
689    Ok(())
690}
691
692/// [`inflate`] over the candidates in `region`, with its cost following them
693/// instead of the mesh: the same vertices move to the same positions, bit for
694/// bit, as long as `region` is ascending and holds every vertex under the
695/// brush that is used by a triangle. Candidates outside the brush, or dead,
696/// are ignored.
697///
698/// # Errors
699/// As for [`inflate`], and [`OpsError::InvalidInput`] without an incidence
700/// index ([`Mesh::ensure_incidence`] before the transaction, and no topology
701/// edit in it). Nothing is written in any of these cases.
702pub fn inflate_region(
703    tx: &mut Transaction,
704    options: &InflateOptions,
705    region: &[VertexId],
706) -> Result<InflateReport> {
707    let brush = &options.brush;
708    check_inflate(brush)?;
709    let mesh = tx.mesh();
710    let index = region_index(mesh)?;
711    let positions = mesh.positions();
712    let mut report = InflateReport::default();
713    let mut ids: Vec<VertexId> = Vec::new();
714    let mut moved_to: Vec<[f32; 3]> = Vec::new();
715    for &v in region {
716        if !mesh.is_vertex_alive(v) {
717            continue;
718        }
719        let p = &positions[v as usize];
720        let Some(w) = brush_weight(brush, p) else {
721            continue;
722        };
723        // As `compute_vertex_normals`: face normals in ascending triangle
724        // order, twice the area long, then normalized.
725        let mut n = [0.0f32; 3];
726        for &t in index.triangles(v) {
727            if let Some([a, b, c]) = mesh.triangle(t) {
728                let (pa, pb, pc) = (
729                    positions[a as usize],
730                    positions[b as usize],
731                    positions[c as usize],
732                );
733                let e1 = [pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]];
734                let e2 = [pc[0] - pa[0], pc[1] - pa[1], pc[2] - pa[2]];
735                n[0] += e1[1] * e2[2] - e1[2] * e2[1];
736                n[1] += e1[2] * e2[0] - e1[0] * e2[2];
737                n[2] += e1[0] * e2[1] - e1[1] * e2[0];
738            }
739        }
740        let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
741        if len > 0.0 {
742            n.iter_mut().for_each(|x| *x /= len);
743        }
744        let length = n.iter().map(|n| (w * n).powi(2)).sum::<f32>().sqrt();
745        if !length.is_finite() || length <= 0.0 {
746            continue;
747        }
748        push(&mut ids, v)?;
749        push(
750            &mut moved_to,
751            [p[0] + w * n[0], p[1] + w * n[1], p[2] + w * n[2]],
752        )?;
753        report.max_displacement = report.max_displacement.max(length);
754    }
755    report.moved = ids.len();
756    write_moves(tx, &ids, &moved_to)?;
757    Ok(report)
758}
759
760/// [`smooth`] over the candidates in `region`, with its cost following them
761/// instead of the mesh; see [`inflate_region`] for what `region` must hold.
762///
763/// # Errors
764/// As for [`inflate_region`].
765pub fn smooth_region(
766    tx: &mut Transaction,
767    options: &SmoothOptions,
768    region: &[VertexId],
769) -> Result<SmoothReport> {
770    /// A neighbor: a region member (it moves), or a fixed position.
771    #[derive(Clone, Copy)]
772    enum Source {
773        Member(u32),
774        Fixed([f32; 3]),
775    }
776    let brush = &options.brush;
777    check_smooth(brush)?;
778    let mut report = SmoothReport::default();
779    if options.cycles == 0 {
780        return Ok(report);
781    }
782    let mesh = tx.mesh();
783    let index = region_index(mesh)?;
784    let positions = mesh.positions();
785    let mut members: Vec<(VertexId, f32)> = Vec::new();
786    for &v in region {
787        if mesh.is_vertex_alive(v)
788            && let Some(w) = brush_weight(brush, &positions[v as usize])
789        {
790            push(&mut members, (v, w))?;
791        }
792    }
793    // Each member's neighbors once, ascending, resolved up front so that the
794    // passes only touch region-sized buffers.
795    let mut starts: Vec<u32> = Vec::new();
796    reserve(&mut starts, members.len() + 1)?;
797    starts.push(0);
798    let mut sources: Vec<Source> = Vec::new();
799    let mut adjacent: Vec<VertexId> = Vec::new();
800    for &(v, _) in &members {
801        adjacent.clear();
802        for &t in index.triangles(v) {
803            if let Some([a, b, c]) = mesh.triangle(t)
804                && a != b
805                && b != c
806                && a != c
807            {
808                for u in [a, b, c] {
809                    if u != v {
810                        push(&mut adjacent, u)?;
811                    }
812                }
813            }
814        }
815        adjacent.sort_unstable();
816        adjacent.dedup();
817        for &u in &adjacent {
818            let source = match members.binary_search_by_key(&u, |m| m.0) {
819                Ok(k) => Source::Member(k as u32),
820                Err(_) => Source::Fixed(positions[u as usize]),
821            };
822            push(&mut sources, source)?;
823        }
824        starts.push(sources.len() as u32);
825    }
826    let mut current: Vec<[f32; 3]> = Vec::new();
827    reserve(&mut current, members.len())?;
828    current.extend(members.iter().map(|&(v, _)| positions[v as usize]));
829    let mut before: Vec<[f32; 3]> = Vec::new();
830    reserve(&mut before, members.len())?;
831    let passes: &[f32] = match options.method {
832        SmoothMethod::Taubin => &[TAUBIN_LAMBDA, TAUBIN_MU],
833        SmoothMethod::Laplacian => &[1.0],
834    };
835    for _ in 0..options.cycles {
836        for &k in passes {
837            before.clear();
838            before.extend_from_slice(&current);
839            for (m, &(_, w)) in members.iter().enumerate() {
840                let adjacent = &sources[starts[m] as usize..starts[m + 1] as usize];
841                if adjacent.is_empty() {
842                    continue;
843                }
844                let mut centroid = [0.0f32; 3];
845                for source in adjacent {
846                    let q = match *source {
847                        Source::Member(u) => before[u as usize],
848                        Source::Fixed(q) => q,
849                    };
850                    for axis in 0..3 {
851                        centroid[axis] += q[axis];
852                    }
853                }
854                for c in &mut centroid {
855                    *c /= adjacent.len() as f32;
856                }
857                let p = before[m];
858                for axis in 0..3 {
859                    current[m][axis] = p[axis] + w * k * (centroid[axis] - p[axis]);
860                }
861            }
862        }
863    }
864    let mut ids: Vec<VertexId> = Vec::new();
865    let mut moved_to: Vec<[f32; 3]> = Vec::new();
866    for (m, &(v, _)) in members.iter().enumerate() {
867        let length = (0..3)
868            .map(|k| (current[m][k] - positions[v as usize][k]).powi(2))
869            .sum::<f32>()
870            .sqrt();
871        if !length.is_finite() || length <= 0.0 {
872            continue;
873        }
874        push(&mut ids, v)?;
875        push(&mut moved_to, current[m])?;
876        report.max_displacement = report.max_displacement.max(length);
877    }
878    report.moved = ids.len();
879    write_moves(tx, &ids, &moved_to)?;
880    Ok(report)
881}
882
883/// [`grab`] over the candidates in `region`, with its cost following them
884/// instead of the mesh; see [`inflate_region`] for what `region` must hold.
885/// Grab needs no incidence index. A vertex no triangle uses moves only if it
886/// is listed.
887///
888/// # Errors
889/// As for [`grab`].
890pub fn grab_region(
891    tx: &mut Transaction,
892    options: &GrabOptions,
893    region: &[VertexId],
894) -> Result<GrabReport> {
895    let brush = &options.brush;
896    check_grab(options)?;
897    let mesh = tx.mesh();
898    let positions = mesh.positions();
899    let mut report = GrabReport::default();
900    let mut ids: Vec<VertexId> = Vec::new();
901    let mut moved_to: Vec<[f32; 3]> = Vec::new();
902    for &v in region {
903        if !mesh.is_vertex_alive(v) {
904            continue;
905        }
906        let p = &positions[v as usize];
907        let Some(w) = brush_weight(brush, p) else {
908            continue;
909        };
910        let d = options.direction;
911        let length = d.iter().map(|d| (w * d).powi(2)).sum::<f32>().sqrt();
912        if !length.is_finite() || length <= 0.0 {
913            continue;
914        }
915        push(&mut ids, v)?;
916        push(
917            &mut moved_to,
918            [p[0] + w * d[0], p[1] + w * d[1], p[2] + w * d[2]],
919        )?;
920        report.max_displacement = report.max_displacement.max(length);
921    }
922    report.moved = ids.len();
923    write_moves(tx, &ids, &moved_to)?;
924    Ok(report)
925}
926
927#[cfg(test)]
928mod tests {
929    use fast_mesh::compute_vertex_normals;
930    use fast_mesh_store::Mesh;
931
932    use super::*;
933    use crate::OpsError;
934    use crate::fail_allocations_after;
935    use crate::test_util::{edit, mesh_of, parts, sweep};
936
937    /// A 3x3 flat grid in `z = 0`, spacing 1, centered at the origin; vertex
938    /// `(x, y)` has id `y * 3 + x`, wound counter-clockwise from `+z`.
939    fn centered_grid() -> Mesh {
940        let mut positions = Vec::new();
941        for y in 0..3 {
942            for x in 0..3 {
943                positions.extend([x as f32 - 1.0, y as f32 - 1.0, 0.0]);
944            }
945        }
946        let mut triangles = Vec::new();
947        for y in 0..2u32 {
948            for x in 0..2u32 {
949                let a = y * 3 + x;
950                triangles.extend([[a, a + 1, a + 4], [a, a + 4, a + 3]]);
951            }
952        }
953        mesh_of(&positions, &triangles)
954    }
955
956    /// The smooth normal of the center vertex (id 4), straight from
957    /// `compute_vertex_normals`, whatever its sign convention.
958    fn center_normal(mesh: &Mesh) -> [f32; 3] {
959        let (positions, triangles) = parts(mesh);
960        let indices: Vec<u32> = triangles.iter().flatten().copied().collect();
961        let normals = compute_vertex_normals(&positions, &indices).unwrap();
962        [normals[4 * 3], normals[4 * 3 + 1], normals[4 * 3 + 2]]
963    }
964
965    fn options(center: [f32; 3], radius: f32, falloff: Falloff, strength: f32) -> InflateOptions {
966        InflateOptions::builder()
967            .center(center)
968            .radius(radius)
969            .falloff(falloff)
970            .strength(strength)
971            .build()
972    }
973
974    /// Displacement length of vertex `id` between two flat position buffers.
975    fn displacement(old: &[f32], new: &[f32], id: usize) -> f32 {
976        (0..3)
977            .map(|k| (new[id * 3 + k] - old[id * 3 + k]).powi(2))
978            .sum::<f32>()
979            .sqrt()
980    }
981
982    #[test]
983    fn falloff_exact_values() {
984        assert!((falloff_value(Falloff::Linear, 0.5) - 0.5).abs() < 1e-6);
985        assert!((falloff_value(Falloff::Smooth, 0.0) - 1.0).abs() < 1e-6);
986        assert!((falloff_value(Falloff::Smooth, 1.0) - 0.0).abs() < 1e-6);
987    }
988
989    #[test]
990    fn falloff_boundary_values() {
991        assert!((falloff_value(Falloff::Linear, 0.0) - 1.0).abs() < 1e-6);
992        assert!((falloff_value(Falloff::Linear, 1.0) - 0.0).abs() < 1e-6);
993        assert!((falloff_value(Falloff::Smooth, 0.5) - 0.5).abs() < 1e-6);
994    }
995
996    #[test]
997    fn inflate_moves_center_along_normal() {
998        let mut mesh = centered_grid();
999        let old = parts(&mesh).0;
1000        let n = center_normal(&mesh);
1001        let options = options([0.0; 3], 10.0, Falloff::Linear, 2.0);
1002        let (report, empty) = edit(&mut mesh, |tx| inflate(tx, &options));
1003        assert!(!empty);
1004        assert_eq!(report.moved, 9);
1005        assert!((report.max_displacement - 2.0).abs() < 1e-4);
1006        let new = parts(&mesh).0;
1007        for k in 0..3 {
1008            assert!(
1009                (new[4 * 3 + k] - (old[4 * 3 + k] + 2.0 * n[k])).abs() < 1e-4,
1010                "center follows strength * normal"
1011            );
1012        }
1013        assert!(displacement(&old, &new, 0) < displacement(&old, &new, 4));
1014    }
1015
1016    #[test]
1017    fn inflate_negative_strength_deflates() {
1018        let mut mesh = centered_grid();
1019        let old = parts(&mesh).0;
1020        let n = center_normal(&mesh);
1021        let options = options([0.0; 3], 10.0, Falloff::Linear, -2.0);
1022        let (report, empty) = edit(&mut mesh, |tx| inflate(tx, &options));
1023        assert!(!empty);
1024        assert_eq!(report.moved, 9);
1025        assert!((report.max_displacement - 2.0).abs() < 1e-4);
1026        let new = parts(&mesh).0;
1027        for k in 0..3 {
1028            assert!(
1029                (new[4 * 3 + k] - (old[4 * 3 + k] - 2.0 * n[k])).abs() < 1e-4,
1030                "negative strength pulls along the normal"
1031            );
1032        }
1033    }
1034
1035    #[test]
1036    fn inflate_empty_region_moves_nothing() {
1037        let mut mesh = centered_grid();
1038        let before = parts(&mesh);
1039        let options = options([100.0, 0.0, 0.0], 1.0, Falloff::Linear, 2.0);
1040        let mut tx = mesh.begin_edit();
1041        let report = inflate(&mut tx, &options).unwrap();
1042        assert_eq!(report.moved, 0);
1043        assert_eq!(report.max_displacement, 0.0);
1044        assert!(tx.commit().unwrap().is_empty());
1045        assert_eq!(parts(&mesh), before);
1046    }
1047
1048    #[test]
1049    fn inflate_zero_radius_rejected() {
1050        let mut mesh = centered_grid();
1051        let options = options([0.0; 3], 0.0, Falloff::Linear, 2.0);
1052        let mut tx = mesh.begin_edit();
1053        assert!(matches!(
1054            inflate(&mut tx, &options),
1055            Err(OpsError::InvalidInput(_))
1056        ));
1057        assert!(tx.commit().unwrap().is_empty());
1058    }
1059
1060    #[test]
1061    fn inflate_negative_radius_rejected() {
1062        let mut mesh = centered_grid();
1063        let options = options([0.0; 3], -1.0, Falloff::Linear, 2.0);
1064        let mut tx = mesh.begin_edit();
1065        assert!(matches!(
1066            inflate(&mut tx, &options),
1067            Err(OpsError::InvalidInput(_))
1068        ));
1069        assert!(tx.commit().unwrap().is_empty());
1070    }
1071
1072    #[test]
1073    fn inflate_nan_strength_rejected() {
1074        let mut mesh = centered_grid();
1075        let options = options([0.0; 3], 10.0, Falloff::Linear, f32::NAN);
1076        let mut tx = mesh.begin_edit();
1077        assert!(matches!(
1078            inflate(&mut tx, &options),
1079            Err(OpsError::InvalidInput(_))
1080        ));
1081        assert!(tx.commit().unwrap().is_empty());
1082    }
1083
1084    fn smooth_options(
1085        center: [f32; 3],
1086        radius: f32,
1087        falloff: Falloff,
1088        strength: f32,
1089        cycles: u32,
1090    ) -> SmoothOptions {
1091        SmoothOptions::builder()
1092            .center(center)
1093            .radius(radius)
1094            .falloff(falloff)
1095            .strength(strength)
1096            .cycles(cycles)
1097            .build()
1098    }
1099
1100    #[test]
1101    fn taubin_pyramid_exact_after_one_cycle() {
1102        assert_eq!(TAUBIN_LAMBDA, 0.33);
1103        assert_eq!(TAUBIN_MU, -0.34);
1104        // Open pyramid: base A(0,0,0) B(1,0,0) C(1,1,0) D(0,1,0), apex
1105        // E(0.5,0.5,1); 4 side triangles. `VertexFans::build` only needs
1106        // proper triangles, so the open base needs no closing: E's fan is
1107        // the 4 side triangles either way.
1108        let positions = [
1109            0.0, 0.0, 0.0, // A
1110            1.0, 0.0, 0.0, // B
1111            1.0, 1.0, 0.0, // C
1112            0.0, 1.0, 0.0, // D
1113            0.5, 0.5, 1.0, // E
1114        ];
1115        let triangles = [[0, 1, 4], [1, 2, 4], [2, 3, 4], [3, 0, 4]];
1116        let mut mesh = mesh_of(&positions, &triangles);
1117        let options = smooth_options([0.5, 0.5, 1.0], 0.1, Falloff::Linear, 1.0, 1);
1118        let (report, empty) = edit(&mut mesh, |tx| smooth(tx, &options));
1119        assert!(!empty);
1120        assert_eq!(report.moved, 1);
1121        let (flat, _) = parts(&mesh);
1122        // E's neighbors (A-D) stay fixed outside the region, centroid
1123        // (0.5,0.5,0): λ pass z = 1 + 0.33 * (0 - 1) = 0.67, then μ pass
1124        // z = 0.67 - 0.34 * (0 - 0.67) = 0.8978.
1125        assert!((flat[4 * 3 + 2] - 0.8978).abs() < 1e-4);
1126        assert_eq!(flat[4 * 3], 0.5);
1127        assert_eq!(flat[4 * 3 + 1], 0.5);
1128        for v in 0..4 {
1129            assert_eq!(&flat[v * 3..v * 3 + 3], &positions[v * 3..v * 3 + 3]);
1130        }
1131        assert!((report.max_displacement - (1.0 - 0.8978)).abs() < 1e-4);
1132    }
1133
1134    #[test]
1135    fn laplacian_pyramid_melts_to_centroid() {
1136        assert_eq!(
1137            SmoothOptions::builder().build().method,
1138            SmoothMethod::Taubin
1139        );
1140        // Same open pyramid as the Taubin test: apex E(0.5,0.5,1) over base
1141        // centroid (0.5,0.5,0).
1142        let positions = [
1143            0.0, 0.0, 0.0, // A
1144            1.0, 0.0, 0.0, // B
1145            1.0, 1.0, 0.0, // C
1146            0.0, 1.0, 0.0, // D
1147            0.5, 0.5, 1.0, // E
1148        ];
1149        let triangles = [[0, 1, 4], [1, 2, 4], [2, 3, 4], [3, 0, 4]];
1150        let mut mesh = mesh_of(&positions, &triangles);
1151        let options = SmoothOptions::builder()
1152            .center([0.5, 0.5, 1.0])
1153            .radius(0.1)
1154            .falloff(Falloff::Linear)
1155            .strength(1.0)
1156            .cycles(1)
1157            .method(SmoothMethod::Laplacian)
1158            .build();
1159        let (report, empty) = edit(&mut mesh, |tx| smooth(tx, &options));
1160        assert!(!empty);
1161        assert_eq!(report.moved, 1);
1162        let (flat, _) = parts(&mesh);
1163        // One full pass at weight 1: E lands exactly on its centroid.
1164        assert_eq!(&flat[4 * 3..4 * 3 + 3], &[0.5, 0.5, 0.0]);
1165        assert!((report.max_displacement - 1.0).abs() < 1e-6);
1166        // Half strength, two passes: 1 -> 0.5 -> 0.25.
1167        let mut mesh = mesh_of(&positions, &triangles);
1168        let options = SmoothOptions::builder()
1169            .center([0.5, 0.5, 1.0])
1170            .radius(0.1)
1171            .falloff(Falloff::Linear)
1172            .strength(0.5)
1173            .cycles(2)
1174            .method(SmoothMethod::Laplacian)
1175            .build();
1176        edit(&mut mesh, |tx| smooth(tx, &options).map(drop));
1177        let (flat, _) = parts(&mesh);
1178        assert!((flat[4 * 3 + 2] - 0.25).abs() < 1e-6);
1179    }
1180
1181    #[test]
1182    fn smooth_zero_cycles_moves_nothing() {
1183        let mut mesh = centered_grid();
1184        let before = parts(&mesh);
1185        let options = smooth_options([0.0; 3], 10.0, Falloff::Linear, 1.0, 0);
1186        let mut tx = mesh.begin_edit();
1187        let report = smooth(&mut tx, &options).unwrap();
1188        assert_eq!(report.moved, 0);
1189        assert_eq!(report.max_displacement, 0.0);
1190        assert!(tx.commit().unwrap().is_empty());
1191        assert_eq!(parts(&mesh), before);
1192    }
1193
1194    #[test]
1195    fn smooth_empty_region_moves_nothing() {
1196        let mut mesh = centered_grid();
1197        let before = parts(&mesh);
1198        let options = smooth_options([100.0, 0.0, 0.0], 1.0, Falloff::Linear, 1.0, 1);
1199        let mut tx = mesh.begin_edit();
1200        let report = smooth(&mut tx, &options).unwrap();
1201        assert_eq!(report.moved, 0);
1202        assert_eq!(report.max_displacement, 0.0);
1203        assert!(tx.commit().unwrap().is_empty());
1204        assert_eq!(parts(&mesh), before);
1205    }
1206
1207    #[test]
1208    fn smooth_invalid_brush_rejected() {
1209        let mut mesh = centered_grid();
1210        let cases = [
1211            smooth_options([0.0; 3], 0.0, Falloff::Linear, 1.0, 1),
1212            smooth_options([0.0; 3], -1.0, Falloff::Linear, 1.0, 1),
1213            smooth_options([0.0; 3], f32::NAN, Falloff::Linear, 1.0, 1),
1214            smooth_options([0.0; 3], 10.0, Falloff::Linear, f32::NAN, 1),
1215            smooth_options([f32::INFINITY, 0.0, 0.0], 10.0, Falloff::Linear, 1.0, 1),
1216        ];
1217        for options in cases {
1218            let mut tx = mesh.begin_edit();
1219            assert!(matches!(
1220                smooth(&mut tx, &options),
1221                Err(OpsError::InvalidInput(_))
1222            ));
1223            assert!(tx.commit().unwrap().is_empty());
1224        }
1225    }
1226
1227    #[test]
1228    fn smooth_nonmanifold_fan_moves_toward_centroid() {
1229        // Two triangles sharing only vertex E(0), with disjoint other
1230        // vertices: E's neighbors are all four of them.
1231        let positions = [
1232            0.0, 0.0, 1.0, // E
1233            0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0,
1234        ];
1235        let triangles = [[0, 1, 2], [0, 3, 4]];
1236        let mut mesh = mesh_of(&positions, &triangles);
1237        let options = smooth_options([0.0, 0.0, 1.0], 0.1, Falloff::Linear, 1.0, 1);
1238        let (report, _) = edit(&mut mesh, |tx| smooth(tx, &options));
1239        assert_eq!(report.moved, 1);
1240        let (flat, _) = parts(&mesh);
1241        let dist = |p: &[f32]| ((p[0] - 0.5).powi(2) + (p[1] - 0.5).powi(2) + p[2].powi(2)).sqrt();
1242        assert!(dist(&flat[0..3]) < dist(&positions[0..3]));
1243    }
1244
1245    #[test]
1246    fn inflate_nonfinite_center_rejected() {
1247        let mut mesh = centered_grid();
1248        let options = options([f32::INFINITY, 0.0, 0.0], 10.0, Falloff::Linear, 2.0);
1249        let mut tx = mesh.begin_edit();
1250        assert!(matches!(
1251            inflate(&mut tx, &options),
1252            Err(OpsError::InvalidInput(_))
1253        ));
1254        assert!(tx.commit().unwrap().is_empty());
1255    }
1256
1257    #[test]
1258    fn inflate_empty_region_skips_temporaries() {
1259        // No region member: no fallible reservation may run at all.
1260        let mut mesh = centered_grid();
1261        let before = parts(&mesh);
1262        let options = options([100.0, 0.0, 0.0], 1.0, Falloff::Linear, 2.0);
1263        let mut tx = mesh.begin_edit();
1264        fail_allocations_after(Some(0));
1265        let report = inflate(&mut tx, &options);
1266        fail_allocations_after(None);
1267        let report = report.unwrap();
1268        assert_eq!(report.moved, 0);
1269        assert_eq!(report.max_displacement, 0.0);
1270        assert!(tx.commit().unwrap().is_empty());
1271        assert_eq!(parts(&mesh), before);
1272    }
1273
1274    #[test]
1275    fn smooth_empty_region_skips_temporaries() {
1276        // No region member: no fallible reservation may run at all.
1277        let mut mesh = centered_grid();
1278        let before = parts(&mesh);
1279        let options = smooth_options([100.0, 0.0, 0.0], 1.0, Falloff::Linear, 1.0, 1);
1280        let mut tx = mesh.begin_edit();
1281        fail_allocations_after(Some(0));
1282        let report = smooth(&mut tx, &options);
1283        fail_allocations_after(None);
1284        let report = report.unwrap();
1285        assert_eq!(report.moved, 0);
1286        assert_eq!(report.max_displacement, 0.0);
1287        assert!(tx.commit().unwrap().is_empty());
1288        assert_eq!(parts(&mesh), before);
1289    }
1290
1291    #[test]
1292    fn inflate_out_of_memory_is_reported_and_writes_nothing() {
1293        let options = options([0.0; 3], 10.0, Falloff::Linear, 2.0);
1294        assert!(sweep(centered_grid(), |tx| inflate(tx, &options).map(drop)) > 5);
1295    }
1296
1297    #[test]
1298    fn smooth_out_of_memory_is_reported_and_writes_nothing() {
1299        let options = smooth_options([0.0; 3], 10.0, Falloff::Linear, 1.0, 1);
1300        assert!(sweep(centered_grid(), |tx| smooth(tx, &options).map(drop)) > 5);
1301    }
1302
1303    fn grab_options(
1304        center: [f32; 3],
1305        radius: f32,
1306        falloff: Falloff,
1307        strength: f32,
1308        direction: [f32; 3],
1309    ) -> GrabOptions {
1310        GrabOptions::builder()
1311            .center(center)
1312            .radius(radius)
1313            .falloff(falloff)
1314            .strength(strength)
1315            .direction(direction)
1316            .build()
1317    }
1318
1319    #[test]
1320    fn grab_moves_region_along_direction() {
1321        let mut mesh = centered_grid();
1322        let old = parts(&mesh).0;
1323        let options = grab_options([0.0; 3], 10.0, Falloff::Linear, 1.0, [2.0, 0.0, 0.0]);
1324        let (report, empty) = edit(&mut mesh, |tx| grab(tx, &options));
1325        assert!(!empty);
1326        assert_eq!(report.moved, 9);
1327        assert!((report.max_displacement - 2.0).abs() < 1e-4);
1328        let new = parts(&mesh).0;
1329        for k in 0..3 {
1330            let want = [2.0, 0.0, 0.0][k];
1331            assert!(
1332                (new[4 * 3 + k] - (old[4 * 3 + k] + want)).abs() < 1e-4,
1333                "center follows strength * direction"
1334            );
1335        }
1336        assert!(displacement(&old, &new, 0) < displacement(&old, &new, 4));
1337    }
1338
1339    #[test]
1340    fn grab_strength_scales_the_drag() {
1341        let mut mesh = centered_grid();
1342        let old = parts(&mesh).0;
1343        let options = grab_options([0.0; 3], 10.0, Falloff::Linear, 0.5, [2.0, 0.0, 0.0]);
1344        let (report, _) = edit(&mut mesh, |tx| grab(tx, &options));
1345        assert_eq!(report.moved, 9);
1346        let new = parts(&mesh).0;
1347        assert!((new[4 * 3] - (old[4 * 3] + 1.0)).abs() < 1e-4);
1348    }
1349
1350    #[test]
1351    fn grab_zero_direction_moves_nothing() {
1352        let mut mesh = centered_grid();
1353        let before = parts(&mesh);
1354        let options = grab_options([0.0; 3], 10.0, Falloff::Linear, 1.0, [0.0; 3]);
1355        let mut tx = mesh.begin_edit();
1356        let report = grab(&mut tx, &options).unwrap();
1357        assert_eq!(report.moved, 0);
1358        assert_eq!(report.max_displacement, 0.0);
1359        assert!(tx.commit().unwrap().is_empty());
1360        assert_eq!(parts(&mesh), before);
1361    }
1362
1363    #[test]
1364    fn grab_empty_region_moves_nothing() {
1365        let mut mesh = centered_grid();
1366        let before = parts(&mesh);
1367        let options = grab_options(
1368            [100.0, 0.0, 0.0],
1369            1.0,
1370            Falloff::Linear,
1371            1.0,
1372            [2.0, 0.0, 0.0],
1373        );
1374        let mut tx = mesh.begin_edit();
1375        let report = grab(&mut tx, &options).unwrap();
1376        assert_eq!(report.moved, 0);
1377        assert_eq!(report.max_displacement, 0.0);
1378        assert!(tx.commit().unwrap().is_empty());
1379        assert_eq!(parts(&mesh), before);
1380    }
1381
1382    #[test]
1383    fn grab_invalid_brush_rejected() {
1384        let mut mesh = centered_grid();
1385        let cases = [
1386            grab_options([0.0; 3], 0.0, Falloff::Linear, 1.0, [1.0, 0.0, 0.0]),
1387            grab_options([0.0; 3], -1.0, Falloff::Linear, 1.0, [1.0, 0.0, 0.0]),
1388            grab_options([0.0; 3], f32::NAN, Falloff::Linear, 1.0, [1.0, 0.0, 0.0]),
1389            grab_options([0.0; 3], 10.0, Falloff::Linear, f32::NAN, [1.0, 0.0, 0.0]),
1390            grab_options(
1391                [f32::INFINITY, 0.0, 0.0],
1392                10.0,
1393                Falloff::Linear,
1394                1.0,
1395                [1.0, 0.0, 0.0],
1396            ),
1397            grab_options([0.0; 3], 10.0, Falloff::Linear, 1.0, [f32::NAN, 0.0, 0.0]),
1398            grab_options(
1399                [0.0; 3],
1400                10.0,
1401                Falloff::Linear,
1402                1.0,
1403                [0.0, f32::INFINITY, 0.0],
1404            ),
1405        ];
1406        for options in cases {
1407            let mut tx = mesh.begin_edit();
1408            assert!(matches!(
1409                grab(&mut tx, &options),
1410                Err(OpsError::InvalidInput(_))
1411            ));
1412            assert!(tx.commit().unwrap().is_empty());
1413        }
1414    }
1415
1416    #[test]
1417    fn grab_empty_region_skips_temporaries() {
1418        // No region member: no fallible reservation may run at all.
1419        let mut mesh = centered_grid();
1420        let before = parts(&mesh);
1421        let options = grab_options(
1422            [100.0, 0.0, 0.0],
1423            1.0,
1424            Falloff::Linear,
1425            1.0,
1426            [2.0, 0.0, 0.0],
1427        );
1428        let mut tx = mesh.begin_edit();
1429        fail_allocations_after(Some(0));
1430        let report = grab(&mut tx, &options);
1431        fail_allocations_after(None);
1432        let report = report.unwrap();
1433        assert_eq!(report.moved, 0);
1434        assert_eq!(report.max_displacement, 0.0);
1435        assert!(tx.commit().unwrap().is_empty());
1436        assert_eq!(parts(&mesh), before);
1437    }
1438
1439    #[test]
1440    fn grab_out_of_memory_is_reported_and_writes_nothing() {
1441        let options = grab_options([0.0; 3], 10.0, Falloff::Linear, 1.0, [2.0, 0.0, 0.0]);
1442        assert!(sweep(centered_grid(), |tx| grab(tx, &options).map(drop)) > 5);
1443    }
1444
1445    /// A wavy grid, `n` quads a side, spacing 1, with a dead triangle.
1446    fn wavy_grid(n: u32) -> Mesh {
1447        let mut positions = Vec::new();
1448        for y in 0..=n {
1449            for x in 0..=n {
1450                let (fx, fy) = (x as f32, y as f32);
1451                positions.extend([fx, fy, (fx * 0.7).sin() * (fy * 0.5).cos()]);
1452            }
1453        }
1454        let mut triangles = Vec::new();
1455        for y in 0..n {
1456            for x in 0..n {
1457                let a = y * (n + 1) + x;
1458                triangles.extend([[a, a + 1, a + n + 2], [a, a + n + 2, a + n + 1]]);
1459            }
1460        }
1461        let mut mesh = mesh_of(&positions, &triangles);
1462        let mut tx = mesh.begin_edit();
1463        tx.remove_triangles(&[10]).unwrap();
1464        tx.commit().unwrap();
1465        mesh.ensure_incidence().unwrap();
1466        mesh
1467    }
1468
1469    /// Every vertex slot, the widest legal region.
1470    fn every_slot(mesh: &Mesh) -> Vec<VertexId> {
1471        (0..mesh.vertex_slots() as VertexId).collect()
1472    }
1473
1474    /// The positions after `f`, with its report.
1475    fn after<R>(mesh: &mut Mesh, f: impl FnOnce(&mut Transaction) -> Result<R>) -> (R, Vec<f32>) {
1476        let (r, _) = edit(mesh, f);
1477        (r, parts(mesh).0)
1478    }
1479
1480    fn bits(v: &[f32]) -> Vec<u32> {
1481        v.iter().map(|x| x.to_bits()).collect()
1482    }
1483
1484    #[test]
1485    fn region_functions_match_the_whole_mesh_ones_bit_for_bit() {
1486        let brush = Brush {
1487            center: [5.3, 4.1, 0.0],
1488            radius: 3.2,
1489            falloff: Falloff::Smooth,
1490            strength: 0.4,
1491        };
1492        // A near region and the full slot list, both ascending.
1493        let near = |mesh: &Mesh| -> Vec<VertexId> {
1494            (0..mesh.vertex_slots() as VertexId)
1495                .filter(|&v| brush_weight(&brush, &mesh.positions()[v as usize]).is_some())
1496                .collect()
1497        };
1498        let inflate_options = InflateOptions { brush };
1499        let (mut a, mut b, mut c) = (wavy_grid(12), wavy_grid(12), wavy_grid(12));
1500        let (ra, pa) = after(&mut a, |tx| inflate(tx, &inflate_options));
1501        let region = near(&b);
1502        let (rb, pb) = after(&mut b, |tx| inflate_region(tx, &inflate_options, &region));
1503        let region = every_slot(&c);
1504        let (rc, pc) = after(&mut c, |tx| inflate_region(tx, &inflate_options, &region));
1505        assert_eq!(ra, rb);
1506        assert_eq!(ra, rc);
1507        assert_eq!(bits(&pa), bits(&pb));
1508        assert_eq!(bits(&pa), bits(&pc));
1509
1510        for method in [SmoothMethod::Taubin, SmoothMethod::Laplacian] {
1511            let smooth_options = SmoothOptions {
1512                brush,
1513                cycles: 3,
1514                method,
1515            };
1516            let (mut a, mut b) = (wavy_grid(12), wavy_grid(12));
1517            let (ra, pa) = after(&mut a, |tx| smooth(tx, &smooth_options));
1518            let region = near(&b);
1519            let (rb, pb) = after(&mut b, |tx| smooth_region(tx, &smooth_options, &region));
1520            assert_eq!(ra, rb);
1521            assert_eq!(bits(&pa), bits(&pb));
1522        }
1523
1524        let grab_options = GrabOptions {
1525            brush,
1526            direction: [0.3, -0.2, 0.9],
1527        };
1528        let (mut a, mut b) = (wavy_grid(12), wavy_grid(12));
1529        let (ra, pa) = after(&mut a, |tx| grab(tx, &grab_options));
1530        let region = near(&b);
1531        let (rb, pb) = after(&mut b, |tx| grab_region(tx, &grab_options, &region));
1532        assert_eq!(ra, rb);
1533        assert_eq!(bits(&pa), bits(&pb));
1534    }
1535
1536    #[test]
1537    fn region_functions_ignore_candidates_outside_the_brush() {
1538        let mut mesh = wavy_grid(6);
1539        let options = GrabOptions {
1540            brush: Brush {
1541                center: [0.0, 0.0, 0.0],
1542                radius: 1.5,
1543                falloff: Falloff::Smooth,
1544                strength: 1.0,
1545            },
1546            direction: [0.0, 0.0, 1.0],
1547        };
1548        let region = every_slot(&mesh);
1549        let (report, _) = after(&mut mesh, |tx| grab_region(tx, &options, &region));
1550        assert!(report.moved > 0 && report.moved < 8);
1551    }
1552
1553    #[test]
1554    fn region_functions_need_the_incidence_index_and_validate() {
1555        let mut mesh = centered_grid();
1556        let region = every_slot(&mesh);
1557        let good = InflateOptions::default();
1558        let mut tx = mesh.begin_edit();
1559        assert!(matches!(
1560            inflate_region(&mut tx, &good, &region),
1561            Err(OpsError::InvalidInput(_))
1562        ));
1563        drop(tx);
1564        mesh.ensure_incidence().unwrap();
1565        let bad = InflateOptions::builder().radius(0.0).build();
1566        let mut tx = mesh.begin_edit();
1567        assert!(matches!(
1568            inflate_region(&mut tx, &bad, &region),
1569            Err(OpsError::InvalidInput(_))
1570        ));
1571        let bad = SmoothOptions::builder().radius(-1.0).cycles(1).build();
1572        assert!(matches!(
1573            smooth_region(&mut tx, &bad, &region),
1574            Err(OpsError::InvalidInput(_))
1575        ));
1576        let bad = GrabOptions::builder().direction([f32::NAN; 3]).build();
1577        assert!(matches!(
1578            grab_region(&mut tx, &bad, &region),
1579            Err(OpsError::InvalidInput(_))
1580        ));
1581    }
1582
1583    /// The largest angle (radians) between neighboring vertex normals, over
1584    /// the grid vertices whose normalized distance from `center` is in `band`.
1585    fn max_normal_step(mesh: &Mesh, n: u32, center: f32, radius: f32, band: (f32, f32)) -> f32 {
1586        let (positions, triangles) = parts(mesh);
1587        let indices: Vec<u32> = triangles.iter().flatten().copied().collect();
1588        let normals = compute_vertex_normals(&positions, &indices).unwrap();
1589        let at = |x: u32, y: u32| ((y * (n + 1) + x) * 3) as usize;
1590        let mut worst = 0.0f32;
1591        for y in 1..n {
1592            for x in 1..n {
1593                let t = (x as f32 - center).hypot(y as f32 - center) / radius;
1594                if t < band.0 || t > band.1 {
1595                    continue;
1596                }
1597                let a = at(x, y);
1598                for b in [at(x + 1, y), at(x, y + 1)] {
1599                    let dot: f32 = (0..3).map(|k| normals[a + k] * normals[b + k]).sum();
1600                    worst = worst.max(dot.clamp(-1.0, 1.0).acos());
1601                }
1602            }
1603        }
1604        worst
1605    }
1606
1607    /// A flat grid inflated once at its centre.
1608    fn flat_dabbed(n: u32, radius: f32, falloff: Falloff) -> Mesh {
1609        let mut positions = Vec::new();
1610        for y in 0..=n {
1611            for x in 0..=n {
1612                positions.extend([x as f32, y as f32, 0.0]);
1613            }
1614        }
1615        let mut triangles = Vec::new();
1616        for y in 0..n {
1617            for x in 0..n {
1618                let a = y * (n + 1) + x;
1619                triangles.extend([[a, a + 1, a + n + 2], [a, a + n + 2, a + n + 1]]);
1620            }
1621        }
1622        let mut mesh = mesh_of(&positions, &triangles);
1623        let c = n as f32 / 2.0;
1624        let options = InflateOptions::builder()
1625            .center([c, c, 0.0])
1626            .radius(radius)
1627            .falloff(falloff)
1628            .strength(radius * 0.25)
1629            .build();
1630        edit(&mut mesh, |tx| inflate(tx, &options));
1631        mesh
1632    }
1633
1634    #[test]
1635    fn smooth_falloff_has_no_crease_at_the_rim() {
1636        // A crease is a jump in the normal that no mesh resolution removes.
1637        // The steepest turn between neighboring normals over the rim, at two
1638        // resolutions of the same relative dab: it halves with the edge length
1639        // for the smooth falloff (the slope is continuous, only the curvature
1640        // jumps), and stays at the full slope, atan(0.25), for the linear one.
1641        let rim_step = |n: u32, falloff: Falloff| {
1642            let radius = n as f32 / 4.0;
1643            let band = (1.0 - 1.5 / radius, 1.0 + 1.5 / radius);
1644            let mesh = flat_dabbed(n, radius, falloff);
1645            max_normal_step(&mesh, n, n as f32 / 2.0, radius, band)
1646        };
1647        let (smooth_coarse, smooth_fine) = (
1648            rim_step(160, Falloff::Smooth),
1649            rim_step(320, Falloff::Smooth),
1650        );
1651        let (linear_coarse, linear_fine) = (
1652            rim_step(160, Falloff::Linear),
1653            rim_step(320, Falloff::Linear),
1654        );
1655        assert!(smooth_coarse < 0.06, "smooth rim step {smooth_coarse}");
1656        assert!(
1657            smooth_fine < 0.6 * smooth_coarse,
1658            "smooth rim step shrinks with the edge: {smooth_coarse} to {smooth_fine}"
1659        );
1660        assert!(linear_coarse > 0.15, "linear rim step {linear_coarse}");
1661        assert!(
1662            linear_fine > 0.9 * linear_coarse,
1663            "linear rim step stays: {linear_coarse} to {linear_fine}"
1664        );
1665    }
1666}