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}