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}