Skip to main content

axiolid_mesh/
attribute.rs

1//! Named per-vertex data carried alongside positions.
2//!
3//! A mesh often arrives with more per-vertex information than geometry:
4//! material ids, texture coordinates, analysis results, source entity
5//! handles. That data is not decoration -- losing it silently is how a
6//! quantity takeoff ends up unable to say which wall a triangle came from.
7//!
8//! Two things make this hard, and both are modelled here rather than
9//! wished away:
10//!
11//! - Not every value can be blended. Interpolating a material id at a new
12//!   vertex invents a material that was never authored, so a channel must
13//!   declare whether blending is even meaningful.
14//! - Some operations genuinely cannot preserve a channel. A boolean cut
15//!   creates vertices with no preimage in either operand. Reporting that
16//!   honestly beats fabricating a plausible value.
17
18use axiolid_core::Scalar;
19
20/// How a channel's values may be combined when a new vertex appears.
21///
22/// This is a property of the DATA, not of the operation. An operation asks
23/// the channel what is permissible; it does not decide on the channel's
24/// behalf.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26#[cfg_attr(
27    feature = "serde",
28    derive(serde::Serialize, serde::Deserialize),
29    serde(deny_unknown_fields)
30)]
31pub enum Blend {
32    /// Values are continuous and may be linearly interpolated.
33    ///
34    /// Appropriate for texture coordinates, temperatures, displacements.
35    Linear,
36    /// Values are labels. A new vertex takes the value of the nearest
37    /// existing one; averaging two ids would invent a third that names
38    /// nothing.
39    Nearest,
40    /// Values cannot be derived for a vertex that did not exist before.
41    ///
42    /// The channel is dropped rather than guessed at.
43    None,
44}
45
46/// Per-vertex or per-corner values under a caller-chosen name.
47///
48/// Per-vertex (the default, `corner_indices: None`): one tuple per position.
49///
50/// Per-corner (`corner_indices: Some`): one index into `values` for every
51/// triangle corner, mirroring [`crate::NormalAttribute::indices`]. This is
52/// how source formats store texture coordinates: a box corner shared by
53/// three faces carries a different UV in each (#112). Positions stay shared,
54/// so the mesh's adjacency and closure do not depend on attribute seams.
55///
56/// Invariants are checked by [`crate::TriMesh::validate_structure`] rather
57/// than enforced at construction, matching how this crate treats dirty
58/// imported data: representable, then validated at a trust boundary.
59#[derive(Debug, Clone, PartialEq)]
60#[cfg_attr(
61    feature = "serde",
62    derive(serde::Serialize, serde::Deserialize),
63    serde(deny_unknown_fields)
64)]
65pub struct AttributeChannel {
66    /// Caller-chosen identifier, unique within a mesh.
67    pub name: String,
68    /// Scalar tuples, `width` entries each: one per vertex, or the pool the
69    /// corner indices point into.
70    pub values: Vec<Scalar>,
71    /// Number of scalars per vertex. `2` for a UV, `1` for an id.
72    pub width: usize,
73    /// How values may be combined when a vertex is created.
74    pub blend: Blend,
75    /// Optional per-corner tuple indices, one per entry of the mesh's
76    /// `indices`. `None` means the channel is per-vertex.
77    ///
78    /// A triangle whose three entries are all [`Self::UNMAPPED`] carries no
79    /// value -- real files texture only some faces. A triangle mixing
80    /// mapped and unmapped corners is invalid: it would have a value at
81    /// some corners and nothing to interpolate towards at the others.
82    pub corner_indices: Option<Vec<u32>>,
83}
84
85impl AttributeChannel {
86    /// Corner-index marker for a triangle that carries no value.
87    pub const UNMAPPED: u32 = u32::MAX;
88
89    /// Build a per-vertex channel from flat values.
90    pub fn new(name: impl Into<String>, values: Vec<Scalar>, width: usize, blend: Blend) -> Self {
91        Self {
92            name: name.into(),
93            values,
94            width,
95            blend,
96            corner_indices: None,
97        }
98    }
99
100    /// Build a per-corner channel: `corner_indices[c]` selects the tuple for
101    /// triangle corner `c`, or is [`Self::UNMAPPED`].
102    pub fn corner_indexed(
103        name: impl Into<String>,
104        values: Vec<Scalar>,
105        width: usize,
106        blend: Blend,
107        corner_indices: Vec<u32>,
108    ) -> Self {
109        Self {
110            corner_indices: Some(corner_indices),
111            ..Self::new(name, values, width, blend)
112        }
113    }
114
115    /// Whether values are addressed per triangle corner.
116    pub fn is_corner_indexed(&self) -> bool {
117        self.corner_indices.is_some()
118    }
119
120    /// Number of tuples in `values`.
121    ///
122    /// Returns `0` for a zero width rather than dividing by it, so a
123    /// malformed channel is inspectable instead of panicking.
124    pub fn value_count(&self) -> usize {
125        if self.width == 0 {
126            return 0;
127        }
128        self.values.len() / self.width
129    }
130
131    /// Number of vertices a per-vertex channel covers.
132    ///
133    /// The same as [`Self::value_count`]; kept for per-vertex callers, where
134    /// a tuple IS a vertex.
135    pub fn vertex_count(&self) -> usize {
136        self.value_count()
137    }
138
139    /// The tuple at a `values` index, or `None` when out of range.
140    ///
141    /// For a per-vertex channel the index is a vertex. For a per-corner one
142    /// it is an entry of `corner_indices`; use [`Self::at_corner`] to go
143    /// from a mesh corner to its value.
144    pub fn get(&self, vertex: usize) -> Option<&[Scalar]> {
145        if self.width == 0 {
146            return None;
147        }
148        let start = vertex.checked_mul(self.width)?;
149        self.values.get(start..start + self.width)
150    }
151
152    /// The tuple at triangle corner `corner` of a mesh whose index buffer is
153    /// `mesh_indices`, whichever way the channel is addressed.
154    ///
155    /// `None` for an unmapped corner or any out-of-range index. That makes
156    /// "no value here" a result the caller has to handle, never a zero.
157    pub fn at_corner(&self, mesh_indices: &[u32], corner: usize) -> Option<&[Scalar]> {
158        let slot = match &self.corner_indices {
159            Some(corners) => *corners.get(corner)?,
160            None => *mesh_indices.get(corner)?,
161        };
162        if slot == Self::UNMAPPED {
163            return None;
164        }
165        self.get(slot as usize)
166    }
167}
168
169/// What happened to a channel during an operation.
170///
171/// Reported rather than rejected, matching how `BooleanEvidence` treats a
172/// no-op cut: the caller is told, and decides whether it matters.
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub enum AttributeFate {
175    /// Every vertex kept its original value.
176    Preserved,
177    /// New vertices received values derived under the channel's blend rule.
178    Interpolated,
179    /// The channel was not carried through, with the reason why.
180    Dropped(DropReason),
181}
182
183impl AttributeFate {
184    /// The fate of a channel that went through `self`, then `next`.
185    ///
186    /// For composed operations (batches, symmetric difference): once dropped
187    /// always dropped, and the FIRST reason is kept -- it is the step that
188    /// lost the data. Otherwise any interpolation makes the whole
189    /// interpolated; only preserved-then-preserved stays preserved.
190    #[must_use]
191    pub fn then(self, next: Self) -> Self {
192        match (self, next) {
193            (Self::Dropped(reason), _) | (_, Self::Dropped(reason)) => Self::Dropped(reason),
194            (Self::Preserved, Self::Preserved) => Self::Preserved,
195            _ => Self::Interpolated,
196        }
197    }
198}
199
200/// Why a channel could not be carried through an operation.
201///
202/// Non-exhaustive: new operations find new reasons, and adding one must not
203/// break every caller that reports them.
204#[non_exhaustive]
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
206pub enum DropReason {
207    /// The operation created vertices and the channel forbids derivation.
208    ///
209    /// Not a failure: [`Blend::None`] is the channel stating that an
210    /// invented value would be worse than an absent one.
211    NotBlendable,
212    /// The provider does not carry attributes through this operation.
213    ///
214    /// Distinct from [`Self::NotBlendable`]: the data could in principle
215    /// have survived, but this implementation does not preserve it. Naming
216    /// the provider's limit separately keeps a capability gap from reading
217    /// as a property of the data.
218    ProviderLimitation,
219    /// Vertices the operation merged carried different values.
220    ///
221    /// A per-vertex channel holds one value per position, so a seam -- two
222    /// coincident vertices with different UVs, say -- cannot survive a weld
223    /// without keeping one side's value for both. Dropping by name is the
224    /// honest answer; a corner-indexed channel is the lossless one.
225    ConflictingValues,
226    /// Inputs being combined define the same channel name with a different
227    /// width or blend, so no single channel can hold them all.
228    ///
229    /// Combining two `"uv"` channels, one 2-wide and one 3-wide, would have
230    /// to invent a component or discard one; dropping by name is the honest
231    /// answer.
232    IncompatibleChannels,
233}