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