Skip to main content

viewport_lib/resources/mesh/
meshes.rs

1use crate::resources::*;
2
3/// CPU-prepared vertex stream and ancillary buffers needed to finish a mesh
4/// upload on the main thread.
5///
6/// Produced by `DeviceResources::prep_mesh_data` and consumed by
7/// `DeviceResources::assemble_mesh_data`. Sits between the worker
8/// thread and the apply step of `begin_upload_mesh_data`.
9pub(crate) struct MeshPrep {
10    /// Interleaved GPU vertex stream (position + normal + uv + tangent +
11    /// colour) ready for upload as `Vec<Vertex>`.
12    pub vertices: Vec<Vertex>,
13    /// Per-vertex normal-line visualisation segments. Two vertices per
14    /// source vertex.
15    pub normal_line_verts: Vec<Vertex>,
16    /// Tangents computed from positions, normals, UVs, and indices when the
17    /// source `MeshData` did not carry its own tangents. `None` means the
18    /// source tangents (if any) should be used directly.
19    pub computed_tangents: Option<Vec<[f32; 4]>>,
20}
21
22impl DeviceResources {
23    /// Create a GpuMesh from vertex/index slices and register it into the resource list.
24    ///
25    /// Returns the `MeshId` of the new mesh.
26    ///
27    /// # Errors
28    ///
29    /// Returns [`ViewportError::EmptyMesh`](crate::error::ViewportError::EmptyMesh) if
30    /// `vertices` or `indices` is empty.
31    ///
32    /// # Examples
33    ///
34    /// ```no_run
35    /// # use viewport_lib::error::ViewportError;
36    /// # fn demo(resources: &mut viewport_lib::resources::DeviceResources, device: &wgpu::Device) {
37    /// let result = resources.upload_mesh(device, &[], &[]);
38    /// assert!(matches!(result, Err(ViewportError::EmptyMesh { .. })));
39    /// # }
40    /// ```
41    pub fn upload_mesh(
42        &mut self,
43        device: &wgpu::Device,
44        vertices: &[Vertex],
45        indices: &[u32],
46    ) -> crate::error::ViewportResult<crate::resources::mesh::mesh_store::MeshId> {
47        if vertices.is_empty() || indices.is_empty() {
48            return Err(crate::error::ViewportError::EmptyMesh {
49                positions: vertices.len(),
50                indices: indices.len(),
51            });
52        }
53        self.frame_upload_bytes += (vertices.len() * std::mem::size_of::<Vertex>()
54            + indices.len() * std::mem::size_of::<u32>()) as u64;
55        let mesh = Self::create_mesh(
56            device,
57            &self.object_bind_group_layout,
58            &self.fallback_texture.view,
59            &self.fallback_normal_map_view,
60            &self.fallback_ao_map_view,
61            &self.material_sampler,
62            &self.lut_sampler,
63            &self.content.fallback_lut_view,
64            &self.content.fallback_scalar_buf,
65            &self.fallback_texture.view,
66            &self.content.fallback_face_colour_buf,
67            &self.content.fallback_warp_buf,
68            &self.content.fallback_position_override_buf,
69            &self.content.fallback_normal_override_buf,
70            &self.fallback_metallic_roughness_texture_view,
71            &self.fallback_emissive_texture_view,
72            vertices,
73            indices,
74        );
75        Ok(self.mesh_store.insert(mesh))
76    }
77
78    /// Upload a `MeshData` (from the geometry primitives module) directly.
79    ///
80    /// Converts positions/normals/indices to the GPU `Vertex` layout (white colour)
81    /// and creates a normal visualization line buffer (light blue #a0c4ff, length 0.1).
82    /// Returns the `MeshId`.
83    ///
84    /// # Errors
85    ///
86    /// Returns [`ViewportError::EmptyMesh`](crate::error::ViewportError::EmptyMesh) if positions or indices are empty,
87    /// [`ViewportError::MeshLengthMismatch`](crate::error::ViewportError::MeshLengthMismatch) if positions and normals differ in length,
88    /// or [`ViewportError::InvalidVertexIndex`](crate::error::ViewportError::InvalidVertexIndex) if an index references a nonexistent vertex.
89    pub fn upload_mesh_data(
90        &mut self,
91        device: &wgpu::Device,
92        data: &MeshData,
93    ) -> crate::error::ViewportResult<crate::resources::mesh::mesh_store::MeshId> {
94        Self::validate_mesh_data(data)?;
95        let prep = Self::prep_mesh_data(data);
96        Ok(self.assemble_mesh_data(device, data, prep))
97    }
98
99    /// CPU-side preparation that converts a `MeshData` into the vertex
100    /// stream, normal-line visualization vertices, and any tangents the
101    /// shader needs.
102    ///
103    /// Split out so it can run on a worker thread for
104    /// `begin_upload_mesh_data`. Returns owned buffers; the caller hands
105    /// them to `assemble_mesh_data` on the main thread to finish the
106    /// upload.
107    pub(crate) fn prep_mesh_data(data: &MeshData) -> MeshPrep {
108        let computed_tangents: Option<Vec<[f32; 4]>> = if data.tangents.is_none() {
109            data.uvs.as_ref().map(|uvs| {
110                Self::compute_tangents(&data.positions, &data.normals, uvs, &data.indices)
111            })
112        } else {
113            None
114        };
115        let tangent_slice = data.tangents.as_deref().or(computed_tangents.as_deref());
116
117        let vertices: Vec<Vertex> = data
118            .positions
119            .iter()
120            .zip(data.normals.iter())
121            .enumerate()
122            .map(|(i, (p, n))| {
123                let uv = data
124                    .uvs
125                    .as_ref()
126                    .and_then(|uvs| uvs.get(i))
127                    .copied()
128                    .unwrap_or([0.0, 0.0]);
129                let tangent = tangent_slice
130                    .and_then(|ts| ts.get(i))
131                    .copied()
132                    .unwrap_or([0.0, 0.0, 0.0, 1.0]);
133                Vertex {
134                    position: *p,
135                    normal: *n,
136                    colour: [1.0, 1.0, 1.0, 1.0],
137                    uv,
138                    tangent,
139                }
140            })
141            .collect();
142
143        let normal_line_verts = Self::build_normal_lines(data);
144
145        MeshPrep {
146            vertices,
147            normal_line_verts,
148            computed_tangents,
149        }
150    }
151
152    /// Main-thread half of `upload_mesh_data`: takes the prep buffers,
153    /// creates GPU buffers and bind groups, inserts the mesh into the
154    /// store, and returns the new id.
155    pub(crate) fn assemble_mesh_data(
156        &mut self,
157        device: &wgpu::Device,
158        data: &MeshData,
159        prep: MeshPrep,
160    ) -> crate::resources::mesh::mesh_store::MeshId {
161        let MeshPrep {
162            vertices,
163            normal_line_verts,
164            computed_tangents,
165        } = prep;
166        let tangent_slice = data.tangents.as_deref().or(computed_tangents.as_deref());
167
168        let mut mesh = Self::create_mesh_with_normals(
169            device,
170            &self.object_bind_group_layout,
171            &self.fallback_texture.view,
172            &self.fallback_normal_map_view,
173            &self.fallback_ao_map_view,
174            &self.material_sampler,
175            &self.lut_sampler,
176            &self.content.fallback_lut_view,
177            &self.content.fallback_scalar_buf,
178            &self.fallback_texture.view,
179            &self.content.fallback_face_colour_buf,
180            &self.content.fallback_warp_buf,
181            &self.content.fallback_position_override_buf,
182            &self.content.fallback_normal_override_buf,
183            &self.fallback_metallic_roughness_texture_view,
184            &self.fallback_emissive_texture_view,
185            &vertices,
186            &data.indices,
187            Some(&normal_line_verts),
188        );
189        mesh.cpu_positions = Some(data.positions.clone());
190        mesh.cpu_indices = Some(data.indices.clone());
191        let (attr_bufs, attr_ranges, face_vbuf, face_attr_bufs, face_colour_bufs, vector_attr_bufs) =
192            Self::upload_attributes(
193                device,
194                &data.attributes,
195                &data.positions,
196                &data.normals,
197                &data.indices,
198                data.uvs.as_deref(),
199                tangent_slice,
200            );
201        mesh.attribute_buffers = attr_bufs;
202        mesh.attribute_ranges = attr_ranges;
203        mesh.face_vertex_buffer = face_vbuf;
204        mesh.face_attribute_buffers = face_attr_bufs;
205        mesh.face_colour_buffers = face_colour_bufs;
206        mesh.vector_attribute_buffers = vector_attr_bufs;
207        self.frame_upload_bytes += (vertices.len() * std::mem::size_of::<Vertex>()
208            + data.indices.len() * std::mem::size_of::<u32>())
209            as u64;
210        let id = self.mesh_store.insert(mesh);
211        tracing::debug!(
212            mesh_index = id.index(),
213            vertices = data.positions.len(),
214            indices = data.indices.len(),
215            "mesh uploaded"
216        );
217        id
218    }
219
220    /// Start an asynchronous mesh upload.
221    ///
222    /// Returns immediately with a `JobId`. The CPU prep (tangent
223    /// computation, vertex repack, normal-line build) runs on a worker
224    /// thread; GPU buffer creation and store insertion run on the main
225    /// thread during the next `process_uploads` call after the worker
226    /// finishes. Once the status is `Ready`, call `upload_result_mesh` to
227    /// take the resulting `MeshId`.
228    ///
229    /// Ownership of `data` transfers into the worker. To upload a mesh
230    /// without giving up ownership, clone the `MeshData` at the call site.
231    ///
232    /// # Errors
233    ///
234    /// Returns the same validation errors as `upload_mesh_data` (empty
235    /// mesh, length mismatch, invalid vertex index) before any job is
236    /// submitted.
237    pub fn begin_upload_mesh_data(
238        &mut self,
239        device: &wgpu::Device,
240        data: MeshData,
241    ) -> crate::error::ViewportResult<crate::resources::JobId> {
242        Self::validate_mesh_data(&data)?;
243
244        let slot =
245            crate::resources::ResultSlot::<crate::resources::mesh::mesh_store::MeshId>::new();
246        let slot_for_apply = slot.clone();
247        let device_for_apply = device.clone();
248
249        let id = {
250            let mut runner = self.jobs.lock().expect("upload job runner poisoned");
251            runner.submit_cpu(move |progress| {
252                progress.set(0.1);
253                let prep = DeviceResources::prep_mesh_data(&data);
254                progress.set(0.95);
255                Ok(crate::resources::upload_jobs::JobProduct::with_apply(
256                    Box::new(move |resources: &mut DeviceResources| {
257                        let mesh_id = resources.assemble_mesh_data(&device_for_apply, &data, prep);
258                        slot_for_apply.set(mesh_id);
259                    }),
260                ))
261            })
262        };
263
264        self.job_results
265            .mesh
266            .lock()
267            .expect("mesh result map poisoned")
268            .insert(id, slot);
269        Ok(id)
270    }
271
272    /// Take the `MeshId` produced by a completed `begin_upload_mesh_data`
273    /// job.
274    ///
275    /// Returns `JobNotReady` while the upload is still in flight, and
276    /// `JobResultMissing` for ids that have already been taken, were
277    /// issued by a different upload type, or never existed.
278    pub fn upload_result_mesh(
279        &mut self,
280        id: crate::resources::JobId,
281    ) -> crate::error::ViewportResult<crate::resources::mesh::mesh_store::MeshId> {
282        let mut map = self
283            .job_results
284            .mesh
285            .lock()
286            .expect("mesh result map poisoned");
287        let slot = match map.get(&id) {
288            Some(s) => s.clone(),
289            None => {
290                return Err(crate::error::ViewportError::JobResultMissing {
291                    reason: "unknown id or wrong upload type",
292                });
293            }
294        };
295        match slot.take() {
296            Some(mesh_id) => {
297                map.remove(&id);
298                Ok(mesh_id)
299            }
300            None => Err(crate::error::ViewportError::JobNotReady),
301        }
302    }
303
304    /// Upload a `MeshData` and retain CPU positions and indices for picking.
305    ///
306    /// Equivalent to [`upload_mesh_data`](Self::upload_mesh_data). The CPU
307    /// position and index data is kept so that `renderer.pick()` can test
308    /// FACE, EDGE, and VERTEX hits against this mesh. Use this variant to
309    /// make the intent explicit at the call site.
310    ///
311    /// # Errors
312    ///
313    /// Same as [`upload_mesh_data`](Self::upload_mesh_data).
314    pub fn upload_mesh_data_pickable(
315        &mut self,
316        device: &wgpu::Device,
317        data: &MeshData,
318    ) -> crate::error::ViewportResult<crate::resources::mesh::mesh_store::MeshId> {
319        self.upload_mesh_data(device, data)
320    }
321
322    /// Free or retain the CPU position and index data for an already-uploaded mesh.
323    ///
324    /// `set_pickable(id, false)` drops the retained CPU data, freeing memory.
325    /// The mesh continues to render normally; it will be silently skipped for
326    /// FACE, EDGE, and VERTEX picks after this call.
327    ///
328    /// `set_pickable(id, true)` is a no-op: CPU data is either already present
329    /// (the mesh was uploaded via [`upload_mesh_data`] or
330    /// [`upload_mesh_data_pickable`]) or it was freed and cannot be recovered
331    /// without re-uploading.
332    ///
333    /// Has no effect if `mesh_id` is not found.
334    pub fn set_pickable(
335        &mut self,
336        mesh_id: crate::resources::mesh::mesh_store::MeshId,
337        pickable: bool,
338    ) {
339        if let Some(mesh) = self.mesh_store.get_mut(mesh_id) {
340            if !pickable {
341                mesh.cpu_positions = None;
342                mesh.cpu_indices = None;
343            }
344        }
345    }
346
347    /// Write new positions and normals into an existing mesh without reallocating GPU buffers.
348    ///
349    /// The vertex count must match the original upload exactly. Use this for deforming meshes
350    /// where topology is stable across frames: the index buffer, edge buffer, and bind groups
351    /// are all reused. Colour, UVs, and tangents are written as defaults (white, zero, [0,0,0,1]).
352    ///
353    /// The normal line visualization buffer is also updated in place if it was created at upload time.
354    ///
355    /// Mutually exclusive per frame with
356    /// [`set_position_override_buffer`](Self::set_position_override_buffer) and
357    /// [`set_normal_override_buffer`](Self::set_normal_override_buffer) for the
358    /// same mesh: the two write paths race. A debug assertion fires if both
359    /// are active. To switch from GPU-compute deformation back to CPU writes,
360    /// call [`clear_position_override`](Self::clear_position_override) /
361    /// [`clear_normal_override`](Self::clear_normal_override) first.
362    ///
363    /// # Errors
364    ///
365    /// Returns [`ViewportError::StaleHandle`](crate::error::ViewportError::StaleHandle)
366    /// if `mesh_id` is out of range, [`ViewportError::MeshLengthMismatch`](crate::error::ViewportError::MeshLengthMismatch)
367    /// if `positions` and `normals` differ in length or do not match the existing vertex count.
368    pub fn write_mesh_positions_normals(
369        &mut self,
370        queue: &wgpu::Queue,
371        mesh_id: crate::resources::mesh::mesh_store::MeshId,
372        positions: &[[f32; 3]],
373        normals: &[[f32; 3]],
374    ) -> crate::error::ViewportResult<()> {
375        use bytemuck::cast_slice;
376
377        if !self.mesh_store.contains(mesh_id) {
378            return Err(crate::error::ViewportError::StaleHandle {
379                index: mesh_id.index(),
380                count: self.mesh_store.len(),
381            });
382        }
383        if positions.len() != normals.len() {
384            return Err(crate::error::ViewportError::MeshLengthMismatch {
385                positions: positions.len(),
386                normals: normals.len(),
387            });
388        }
389
390        let existing_vertex_count = {
391            let mesh = self.mesh_store.get(mesh_id).unwrap();
392            debug_assert!(
393                mesh.position_override_buffer.is_none() && mesh.normal_override_buffer.is_none(),
394                "write_mesh_positions_normals called on mesh {} that has a GPU position/normal override bound. The CPU write and the GPU override race; call clear_position_override / clear_normal_override first.",
395                mesh_id.index(),
396            );
397            (mesh.vertex_buffer.size() / std::mem::size_of::<Vertex>() as u64) as usize
398        };
399        if positions.len() != existing_vertex_count {
400            return Err(crate::error::ViewportError::MeshLengthMismatch {
401                positions: positions.len(),
402                normals: existing_vertex_count,
403            });
404        }
405
406        let vertices: Vec<Vertex> = positions
407            .iter()
408            .zip(normals.iter())
409            .map(|(p, n)| Vertex {
410                position: *p,
411                normal: *n,
412                colour: [1.0, 1.0, 1.0, 1.0],
413                uv: [0.0, 0.0],
414                tangent: [0.0, 0.0, 0.0, 1.0],
415            })
416            .collect();
417
418        let has_normal_lines = self
419            .mesh_store
420            .get(mesh_id)
421            .unwrap()
422            .normal_line_buffer
423            .is_some();
424        let normal_line_verts: Option<Vec<Vertex>> = if has_normal_lines {
425            let normal_length = 0.1_f32;
426            let normal_colour = [0.627_f32, 0.769, 1.0, 1.0];
427            let mut verts = Vec::with_capacity(positions.len() * 2);
428            for (p, n) in positions.iter().zip(normals.iter()) {
429                let tip = [
430                    p[0] + n[0] * normal_length,
431                    p[1] + n[1] * normal_length,
432                    p[2] + n[2] * normal_length,
433                ];
434                verts.push(Vertex {
435                    position: *p,
436                    normal: *n,
437                    colour: normal_colour,
438                    uv: [0.0, 0.0],
439                    tangent: [0.0, 0.0, 0.0, 1.0],
440                });
441                verts.push(Vertex {
442                    position: tip,
443                    normal: *n,
444                    colour: normal_colour,
445                    uv: [0.0, 0.0],
446                    tangent: [0.0, 0.0, 0.0, 1.0],
447                });
448            }
449            Some(verts)
450        } else {
451            None
452        };
453
454        let aabb = crate::scene::aabb::Aabb::from_positions(positions);
455        let mesh = self.mesh_store.get_mut(mesh_id).unwrap();
456        queue.write_buffer(&mesh.vertex_buffer, 0, cast_slice(&vertices));
457        if let (Some(nl_buf), Some(nl_verts)) = (&mesh.normal_line_buffer, &normal_line_verts) {
458            queue.write_buffer(nl_buf, 0, cast_slice(nl_verts.as_slice()));
459        }
460        mesh.aabb = aabb;
461        if let Some(ref mut cp) = mesh.cpu_positions {
462            *cp = positions.to_vec();
463        }
464
465        self.frame_upload_bytes += (vertices.len() * std::mem::size_of::<Vertex>()) as u64;
466        if let Some(ref nl) = normal_line_verts {
467            self.frame_upload_bytes += (nl.len() * std::mem::size_of::<Vertex>()) as u64;
468        }
469
470        Ok(())
471    }
472
473    /// Bind a GPU storage buffer of per-vertex positions to `mesh_id`. The
474    /// standard mesh and skinned-mesh pipelines read positions from this
475    /// buffer instead of the vertex buffer's position attribute on every frame
476    /// the binding is present.
477    ///
478    /// Intended consumer: a [`GpuPlugin`](crate::runtime::GpuPlugin) that
479    /// computes deformed positions on the GPU in `pre_prepare` (cloth, hair,
480    /// GPU particles, audio-reactive displacement). The override path
481    /// sidesteps the CPU round-trip that
482    /// [`write_mesh_positions_normals`](Self::write_mesh_positions_normals)
483    /// requires.
484    ///
485    /// The buffer must:
486    ///   - have [`wgpu::BufferUsages::STORAGE`],
487    ///   - hold at least 3 `f32` per vertex (12 bytes each), in flat
488    ///     `[x, y, z, x, y, z, ...]` order. This matches the warp-attribute
489    ///     buffer layout and avoids WGSL's 16-byte vec3 stride padding, so a
490    ///     consumer compute shader can write tight `vec3` data directly.
491    ///
492    /// The shader bounds-checks `arrayLength` before reading, so a smaller
493    /// buffer falls back to `in.position` for out-of-range vertex indices.
494    ///
495    /// Override and skinning compose: when both are active, the override
496    /// replaces the bind-pose input and skinning is then applied on top.
497    ///
498    /// Do not call this in the same frame as `write_mesh_positions_normals`
499    /// for the same mesh; the two write paths race and the result is
500    /// undefined. Pick one source for positions per frame.
501    ///
502    /// # Errors
503    ///
504    /// Returns [`ViewportError::StaleHandle`](crate::error::ViewportError::StaleHandle)
505    /// if `mesh_id` is not registered.
506    pub fn set_position_override_buffer(
507        &mut self,
508        mesh_id: crate::resources::mesh::mesh_store::MeshId,
509        buffer: wgpu::Buffer,
510    ) -> crate::error::ViewportResult<()> {
511        let store_len = self.mesh_store.len();
512        let mesh =
513            self.mesh_store
514                .get_mut(mesh_id)
515                .ok_or(crate::error::ViewportError::StaleHandle {
516                    index: mesh_id.index(),
517                    count: store_len,
518                })?;
519        mesh.position_override_buffer = Some(buffer);
520        // Bump only the gen counter; don't touch `last_tex_key.9` here. The
521        // bind-group rebuild path reads `position_override_gen` into the new
522        // key and compares against `last_tex_key`; the mismatch is what
523        // triggers the rebuild that actually swaps the fallback binding for
524        // this buffer.
525        mesh.position_override_gen = mesh.position_override_gen.wrapping_add(1);
526        Ok(())
527    }
528
529    /// Same idea as [`set_position_override_buffer`](Self::set_position_override_buffer)
530    /// but for per-vertex normals (bound at group 1 binding 14).
531    ///
532    /// # Errors
533    ///
534    /// Returns [`ViewportError::StaleHandle`](crate::error::ViewportError::StaleHandle)
535    /// if `mesh_id` is not registered.
536    pub fn set_normal_override_buffer(
537        &mut self,
538        mesh_id: crate::resources::mesh::mesh_store::MeshId,
539        buffer: wgpu::Buffer,
540    ) -> crate::error::ViewportResult<()> {
541        let store_len = self.mesh_store.len();
542        let mesh =
543            self.mesh_store
544                .get_mut(mesh_id)
545                .ok_or(crate::error::ViewportError::StaleHandle {
546                    index: mesh_id.index(),
547                    count: store_len,
548                })?;
549        mesh.normal_override_buffer = Some(buffer);
550        // See `set_position_override_buffer` for why this only bumps the gen
551        // counter and not `last_tex_key.10`.
552        mesh.normal_override_gen = mesh.normal_override_gen.wrapping_add(1);
553        Ok(())
554    }
555
556    /// Revert the position source to the mesh's vertex buffer attribute.
557    /// Drops the override buffer handle; if no other owner holds it, wgpu
558    /// frees it after the in-flight frames complete.
559    ///
560    /// # Errors
561    ///
562    /// Returns [`ViewportError::StaleHandle`](crate::error::ViewportError::StaleHandle)
563    /// if `mesh_id` is not registered.
564    pub fn clear_position_override(
565        &mut self,
566        mesh_id: crate::resources::mesh::mesh_store::MeshId,
567    ) -> crate::error::ViewportResult<()> {
568        let store_len = self.mesh_store.len();
569        let mesh =
570            self.mesh_store
571                .get_mut(mesh_id)
572                .ok_or(crate::error::ViewportError::StaleHandle {
573                    index: mesh_id.index(),
574                    count: store_len,
575                })?;
576        mesh.position_override_buffer = None;
577        mesh.position_override_gen = mesh.position_override_gen.wrapping_add(1);
578        Ok(())
579    }
580
581    /// Revert the normal source to the mesh's vertex buffer attribute.
582    ///
583    /// # Errors
584    ///
585    /// Returns [`ViewportError::StaleHandle`](crate::error::ViewportError::StaleHandle)
586    /// if `mesh_id` is not registered.
587    pub fn clear_normal_override(
588        &mut self,
589        mesh_id: crate::resources::mesh::mesh_store::MeshId,
590    ) -> crate::error::ViewportResult<()> {
591        let store_len = self.mesh_store.len();
592        let mesh =
593            self.mesh_store
594                .get_mut(mesh_id)
595                .ok_or(crate::error::ViewportError::StaleHandle {
596                    index: mesh_id.index(),
597                    count: store_len,
598                })?;
599        mesh.normal_override_buffer = None;
600        mesh.normal_override_gen = mesh.normal_override_gen.wrapping_add(1);
601        Ok(())
602    }
603
604    /// Replace the mesh at `mesh_index` with new geometry data.
605    ///
606    /// When the new vertex and index counts match the existing mesh and no attributes are
607    /// present, the existing GPU buffers are reused and data is written in place, avoiding
608    /// GPU memory allocation. When topology changes, new buffers are allocated.
609    ///
610    /// This is the only slot-targeting mesh operation, so it doubles as the
611    /// guard against a free racing a queued replace: the `mesh_id` generation is
612    /// checked here (and again in `MeshStore::replace`), so a handle whose mesh
613    /// was freed, or freed and its slot reused by a later upload, is rejected
614    /// rather than overwriting the mesh now in that slot. The async upload paths
615    /// need no such guard because they allocate a fresh slot at apply time.
616    ///
617    /// # Errors
618    ///
619    /// Returns [`ViewportError::StaleHandle`](crate::error::ViewportError::StaleHandle) if `mesh_index` is out of range
620    /// or the handle is stale, or any mesh validation error from the new data.
621    pub fn replace_mesh_data(
622        &mut self,
623        device: &wgpu::Device,
624        queue: &wgpu::Queue,
625        mesh_id: crate::resources::mesh::mesh_store::MeshId,
626        data: &MeshData,
627    ) -> crate::error::ViewportResult<()> {
628        if !self.mesh_store.contains(mesh_id) {
629            return Err(crate::error::ViewportError::StaleHandle {
630                index: mesh_id.index(),
631                count: self.mesh_store.len(),
632            });
633        }
634        Self::validate_mesh_data(data)?;
635
636        let computed_tangents: Option<Vec<[f32; 4]>> = if data.tangents.is_none() {
637            data.uvs.as_ref().map(|uvs| {
638                Self::compute_tangents(&data.positions, &data.normals, uvs, &data.indices)
639            })
640        } else {
641            None
642        };
643        let tangent_slice = data.tangents.as_deref().or(computed_tangents.as_deref());
644
645        let vertices: Vec<Vertex> = data
646            .positions
647            .iter()
648            .zip(data.normals.iter())
649            .enumerate()
650            .map(|(i, (p, n))| {
651                let uv = data
652                    .uvs
653                    .as_ref()
654                    .and_then(|uvs| uvs.get(i))
655                    .copied()
656                    .unwrap_or([0.0, 0.0]);
657                let tangent = tangent_slice
658                    .and_then(|ts| ts.get(i))
659                    .copied()
660                    .unwrap_or([0.0, 0.0, 0.0, 1.0]);
661                Vertex {
662                    position: *p,
663                    normal: *n,
664                    colour: [1.0, 1.0, 1.0, 1.0],
665                    uv,
666                    tangent,
667                }
668            })
669            .collect();
670
671        // Fast path: when topology is unchanged and no attributes need updating, write
672        // directly to the existing GPU buffers to avoid re-allocation.
673        {
674            let existing = self.mesh_store.get(mesh_id).unwrap();
675            let existing_vc =
676                (existing.vertex_buffer.size() / std::mem::size_of::<Vertex>() as u64) as usize;
677            let in_place = existing_vc == vertices.len()
678                && existing.index_count as usize == data.indices.len()
679                && data.attributes.is_empty();
680
681            if in_place {
682                use bytemuck::cast_slice;
683                let edge_indices =
684                    crate::resources::mesh::geometry::generate_edge_indices(&data.indices);
685                let normal_line_verts = Self::build_normal_lines(data);
686                let aabb = crate::scene::aabb::Aabb::from_positions(&data.positions);
687
688                let mesh = self.mesh_store.get_mut(mesh_id).unwrap();
689                queue.write_buffer(&mesh.vertex_buffer, 0, cast_slice(&vertices));
690                queue.write_buffer(&mesh.index_buffer, 0, cast_slice(data.indices.as_slice()));
691                let edge_byte_len = (edge_indices.len() * std::mem::size_of::<u32>()) as u64;
692                if edge_byte_len <= mesh.edge_index_buffer.size() {
693                    queue.write_buffer(&mesh.edge_index_buffer, 0, cast_slice(&edge_indices));
694                    mesh.edge_index_count = edge_indices.len() as u32;
695                }
696                if let Some(ref nl_buf) = mesh.normal_line_buffer {
697                    queue.write_buffer(nl_buf, 0, cast_slice(&normal_line_verts));
698                }
699                mesh.aabb = aabb;
700                mesh.cpu_positions = Some(data.positions.clone());
701                mesh.cpu_indices = Some(data.indices.clone());
702
703                self.frame_upload_bytes += (vertices.len() * std::mem::size_of::<Vertex>()
704                    + data.indices.len() * std::mem::size_of::<u32>())
705                    as u64;
706                tracing::debug!(
707                    mesh_index = mesh_id.index(),
708                    vertices = data.positions.len(),
709                    "mesh updated in place"
710                );
711                return Ok(());
712            }
713        }
714
715        let normal_line_verts = Self::build_normal_lines(data);
716        let mut new_mesh = Self::create_mesh_with_normals(
717            device,
718            &self.object_bind_group_layout,
719            &self.fallback_texture.view,
720            &self.fallback_normal_map_view,
721            &self.fallback_ao_map_view,
722            &self.material_sampler,
723            &self.lut_sampler,
724            &self.content.fallback_lut_view,
725            &self.content.fallback_scalar_buf,
726            &self.fallback_texture.view,
727            &self.content.fallback_face_colour_buf,
728            &self.content.fallback_warp_buf,
729            &self.content.fallback_position_override_buf,
730            &self.content.fallback_normal_override_buf,
731            &self.fallback_metallic_roughness_texture_view,
732            &self.fallback_emissive_texture_view,
733            &vertices,
734            &data.indices,
735            Some(&normal_line_verts),
736        );
737        new_mesh.cpu_positions = Some(data.positions.clone());
738        new_mesh.cpu_indices = Some(data.indices.clone());
739        let (attr_bufs, attr_ranges, face_vbuf, face_attr_bufs, face_colour_bufs, vector_attr_bufs) =
740            Self::upload_attributes(
741                device,
742                &data.attributes,
743                &data.positions,
744                &data.normals,
745                &data.indices,
746                data.uvs.as_deref(),
747                tangent_slice,
748            );
749        new_mesh.attribute_buffers = attr_bufs;
750        new_mesh.attribute_ranges = attr_ranges;
751        new_mesh.face_vertex_buffer = face_vbuf;
752        new_mesh.face_attribute_buffers = face_attr_bufs;
753        new_mesh.face_colour_buffers = face_colour_bufs;
754        new_mesh.vector_attribute_buffers = vector_attr_bufs;
755        self.frame_upload_bytes += (vertices.len() * std::mem::size_of::<Vertex>()
756            + data.indices.len() * std::mem::size_of::<u32>())
757            as u64;
758        let _ = self.mesh_store.replace(mesh_id, new_mesh);
759        tracing::debug!(
760            mesh_index = mesh_id.index(),
761            vertices = data.positions.len(),
762            indices = data.indices.len(),
763            "mesh replaced"
764        );
765        Ok(())
766    }
767
768    /// Get a reference to the mesh at the given index, or `None` if the slot is empty/invalid.
769    pub fn mesh(&self, id: crate::resources::mesh::mesh_store::MeshId) -> Option<&GpuMesh> {
770        self.mesh_store.get(id)
771    }
772
773    /// Total number of mesh slots (including empty/removed slots).
774    pub fn mesh_slot_count(&self) -> usize {
775        self.mesh_store.slot_count()
776    }
777
778    /// Deprecated alias for [`free_mesh`](Self::free_mesh).
779    #[deprecated(note = "renamed to free_mesh")]
780    pub fn remove_mesh(&mut self, id: crate::resources::mesh::mesh_store::MeshId) -> bool {
781        self.free_mesh(id)
782    }
783
784    /// Free a mesh, reclaiming its GPU buffers and slot.
785    ///
786    /// Drops the `GpuMesh` (vertex, index, attribute buffers and its object bind
787    /// group; wgpu defers the real free until in-flight commands complete),
788    /// bumps the slot generation so `id` no longer resolves, and frees the slot
789    /// for a later upload to reuse. A stale `id` held elsewhere degrades to
790    /// fallback rendering rather than aliasing the reused slot.
791    ///
792    /// Returns `true` if a mesh was freed, `false` if `id` did not resolve to a
793    /// live mesh. This is the residency-facing name for [`remove_mesh`]; the two
794    /// are equivalent. To free a mesh that is a member of a LOD group, free the
795    /// group with [`free_lod_group`](Self::free_lod_group) instead so shared
796    /// members are handled.
797    ///
798    /// [`remove_mesh`]: Self::remove_mesh
799    pub fn free_mesh(&mut self, id: crate::resources::mesh::mesh_store::MeshId) -> bool {
800        self.mesh_store.remove(id)
801    }
802
803    /// Upload an unstructured volume mesh and return a ready-to-submit
804    /// [`VolumeMeshItem`](crate::VolumeMeshItem).
805    ///
806    /// Extracts the boundary surface and uploads it through the standard mesh
807    /// pipeline. Interior faces (shared by two cells) are discarded; only
808    /// boundary faces (belonging to exactly one cell) are kept. Per-cell scalar
809    /// and colour attributes are remapped to per-face attributes so the
810    /// face-colouring path handles them automatically.
811    ///
812    /// The returned item has `transparency: None` and `projected_tet_id: None`;
813    /// it renders as an opaque surface mesh. Use
814    /// [`upload_volume_mesh_with_transparency`](Self::upload_volume_mesh_with_transparency)
815    /// instead if you need to toggle volumetric rendering at runtime.
816    pub fn upload_volume_mesh(
817        &mut self,
818        device: &wgpu::Device,
819        data: &crate::resources::volume::volume_mesh::VolumeMeshData,
820    ) -> crate::error::ViewportResult<crate::VolumeMeshItem> {
821        let (mesh_data, face_to_cell) =
822            crate::resources::volume::volume_mesh::extract_boundary_faces(data);
823        let mesh_id = self.upload_mesh_data(device, &mesh_data)?;
824        Ok(crate::VolumeMeshItem::new(mesh_id, face_to_cell))
825    }
826
827    /// Upload an unstructured volume mesh with both the boundary surface and
828    /// the projected-tet decomposition needed for volumetric rendering.
829    ///
830    /// The returned item carries:
831    ///   - `boundary_mesh_id` + `face_to_cell` for the opaque surface draw and
832    ///     boundary-level cell picking,
833    ///   - `projected_tet_id` for the volumetric draw, and
834    ///   - `volume_mesh_data` (an `Arc` over the input) for interior-inclusive
835    ///     cell picking when transparency is on.
836    ///
837    /// Default `transparency: None`: the item renders as a boundary surface
838    /// until the host sets [`VolumeMeshItem::transparency`](crate::VolumeMeshItem::transparency)
839    /// to `Some(VolumeTransparency { .. })`. Switching modes at runtime is free
840    /// because both GPU artifacts are already resident.
841    ///
842    /// `scalar_attribute` names a key in `data.cell_scalars`; cells without the
843    /// attribute receive scalar 0.0. The scalar range is auto-detected from the
844    /// data and stored in the per-volume uniform.
845    pub fn upload_volume_mesh_with_transparency(
846        &mut self,
847        device: &wgpu::Device,
848        data: crate::resources::volume::volume_mesh::VolumeMeshData,
849        scalar_attribute: &str,
850    ) -> crate::error::ViewportResult<crate::VolumeMeshItem> {
851        let (mesh_data, face_to_cell) =
852            crate::resources::volume::volume_mesh::extract_boundary_faces(&data);
853        let mesh_id = self.upload_mesh_data(device, &mesh_data)?;
854        let (pt_id, _, _) = self.upload_projected_tet(device, &data, scalar_attribute)?;
855        let mut item = crate::VolumeMeshItem::new(mesh_id, face_to_cell);
856        item.projected_tet_id = Some(pt_id);
857        item.volume_mesh_data = Some(std::sync::Arc::new(data));
858        Ok(item)
859    }
860
861    /// Upload a clipped volume mesh, returning a ready-to-submit
862    /// [`VolumeMeshItem`](crate::VolumeMeshItem).
863    ///
864    /// Each entry in `clip_planes` is `[nx, ny, nz, d]` where a point `p` is
865    /// kept when `dot(p, [nx, ny, nz]) + d >= 0`. An empty slice is equivalent
866    /// to [`upload_volume_mesh`](Self::upload_volume_mesh).
867    ///
868    /// The returned item has `transparency: None` and `projected_tet_id: None`.
869    pub fn upload_clipped_volume_mesh(
870        &mut self,
871        device: &wgpu::Device,
872        data: &crate::resources::volume::volume_mesh::VolumeMeshData,
873        clip_planes: &[[f32; 4]],
874    ) -> crate::error::ViewportResult<crate::VolumeMeshItem> {
875        let (mesh_data, face_to_cell) =
876            crate::resources::volume::volume_mesh::extract_clipped_volume_faces(data, clip_planes);
877        let mesh_id = self.upload_mesh_data(device, &mesh_data)?;
878        Ok(crate::VolumeMeshItem::new(mesh_id, face_to_cell))
879    }
880
881    /// Replace an existing boundary-mesh slot with a freshly-extracted clipped
882    /// volume mesh, returning the new `face_to_cell` map.
883    ///
884    /// Equivalent to calling [`upload_clipped_volume_mesh`](Self::upload_clipped_volume_mesh)
885    /// and then [`replace_mesh_data`](Self::replace_mesh_data), but without
886    /// allocating a new mesh slot. Use this for per-frame clip-plane updates to
887    /// avoid leaking GPU memory.
888    pub fn replace_clipped_volume_mesh(
889        &mut self,
890        device: &wgpu::Device,
891        queue: &wgpu::Queue,
892        mesh_id: crate::resources::mesh::mesh_store::MeshId,
893        data: &crate::resources::volume::volume_mesh::VolumeMeshData,
894        clip_planes: &[[f32; 4]],
895    ) -> crate::error::ViewportResult<Vec<u32>> {
896        let (mesh_data, face_to_cell) =
897            crate::resources::volume::volume_mesh::extract_clipped_volume_faces(data, clip_planes);
898        self.replace_mesh_data(device, queue, mesh_id, &mesh_data)?;
899        Ok(face_to_cell)
900    }
901
902    /// Replace a previously uploaded sparse voxel grid in place.
903    ///
904    /// Equivalent to calling [`upload_sparse_volume_grid_data`](Self::upload_sparse_volume_grid_data)
905    /// and then [`replace_mesh_data`](Self::replace_mesh_data), but without allocating a new slot.
906    /// Use this for per-frame or per-interaction updates (e.g. voxel paint) to avoid leaking GPU memory.
907    pub fn replace_sparse_volume_grid_data(
908        &mut self,
909        device: &wgpu::Device,
910        queue: &wgpu::Queue,
911        mesh_id: crate::resources::mesh::mesh_store::MeshId,
912        data: &crate::resources::volume::sparse_volume::SparseVolumeGridData,
913    ) -> crate::error::ViewportResult<()> {
914        let mesh_data = crate::resources::volume::sparse_volume::extract_sparse_boundary(data);
915        self.replace_mesh_data(device, queue, mesh_id, &mesh_data)
916    }
917
918    /// Upload a sparse voxel grid by extracting its boundary surface and uploading
919    /// the result via [`upload_mesh_data`](Self::upload_mesh_data).
920    ///
921    /// Only quad faces not shared between two active cells are kept.  Per-cell
922    /// scalars and colours are remapped to per-face attributes, and per-node
923    /// scalars are averaged over the 4 quad corners to produce per-face scalars.
924    ///
925    /// Returns the `MeshId`.  Reference cell and node attributes via
926    /// [`AttributeRef { kind: AttributeKind::Face, .. }`](crate::resources::AttributeRef).
927    pub fn upload_sparse_volume_grid_data(
928        &mut self,
929        device: &wgpu::Device,
930        data: &crate::resources::volume::sparse_volume::SparseVolumeGridData,
931    ) -> crate::error::ViewportResult<crate::resources::mesh::mesh_store::MeshId> {
932        let mesh_data = crate::resources::volume::sparse_volume::extract_sparse_boundary(data);
933        self.upload_mesh_data(device, &mesh_data)
934    }
935
936    /// Start an asynchronous boundary-only volume mesh upload.
937    ///
938    /// Returns a [`JobId`](crate::resources::JobId) immediately. Boundary
939    /// extraction (`extract_boundary_faces`) and vertex prep
940    /// (`prep_mesh_data`) run on a worker thread; the apply step creates
941    /// GPU buffers and inserts the mesh. Take the resulting
942    /// [`VolumeMeshItem`](crate::VolumeMeshItem) via
943    /// [`upload_result_volume_mesh`](Self::upload_result_volume_mesh).
944    ///
945    /// Ownership of `data` transfers into the worker. The returned item has
946    /// `transparency: None`; this async path does not produce a projected-tet
947    /// decomposition. For volumetric rendering use the synchronous
948    /// [`upload_volume_mesh_with_transparency`](Self::upload_volume_mesh_with_transparency).
949    pub fn begin_upload_volume_mesh(
950        &mut self,
951        device: &wgpu::Device,
952        data: crate::resources::volume::volume_mesh::VolumeMeshData,
953    ) -> crate::resources::JobId {
954        let slot = crate::resources::ResultSlot::<(
955            crate::resources::mesh::mesh_store::MeshId,
956            Vec<u32>,
957        )>::new();
958        let slot_for_apply = slot.clone();
959        let device_for_apply = device.clone();
960
961        let id = {
962            let mut runner = self.jobs.lock().expect("upload job runner poisoned");
963            runner.submit_cpu(move |progress| {
964                progress.set(0.1);
965                let (mesh_data, face_to_cell) =
966                    crate::resources::volume::volume_mesh::extract_boundary_faces(&data);
967                progress.set(0.5);
968                DeviceResources::validate_mesh_data(&mesh_data)?;
969                let prep = DeviceResources::prep_mesh_data(&mesh_data);
970                progress.set(0.95);
971                Ok(crate::resources::upload_jobs::JobProduct::with_apply(
972                    Box::new(move |resources: &mut DeviceResources| {
973                        let mesh_id =
974                            resources.assemble_mesh_data(&device_for_apply, &mesh_data, prep);
975                        slot_for_apply.set((mesh_id, face_to_cell));
976                    }),
977                ))
978            })
979        };
980
981        self.job_results
982            .volume_mesh
983            .lock()
984            .expect("volume mesh result map poisoned")
985            .insert(id, slot);
986        id
987    }
988
989    /// Take the [`VolumeMeshItem`](crate::VolumeMeshItem) produced
990    /// by a completed
991    /// [`begin_upload_volume_mesh`](Self::begin_upload_volume_mesh) job.
992    pub fn upload_result_volume_mesh(
993        &mut self,
994        id: crate::resources::JobId,
995    ) -> crate::error::ViewportResult<crate::VolumeMeshItem> {
996        let mut map = self
997            .job_results
998            .volume_mesh
999            .lock()
1000            .expect("volume mesh result map poisoned");
1001        let slot = match map.get(&id) {
1002            Some(s) => s.clone(),
1003            None => {
1004                return Err(crate::error::ViewportError::JobResultMissing {
1005                    reason: "unknown id or wrong upload type",
1006                });
1007            }
1008        };
1009        match slot.take() {
1010            Some((mesh_id, face_to_cell)) => {
1011                map.remove(&id);
1012                Ok(crate::VolumeMeshItem::new(mesh_id, face_to_cell))
1013            }
1014            None => Err(crate::error::ViewportError::JobNotReady),
1015        }
1016    }
1017
1018    /// Start an asynchronous clipped volume mesh upload. See
1019    /// [`upload_clipped_volume_mesh`](Self::upload_clipped_volume_mesh) for the
1020    /// sync analog and the semantics of `clip_planes`.
1021    pub fn begin_upload_clipped_volume_mesh(
1022        &mut self,
1023        device: &wgpu::Device,
1024        data: crate::resources::volume::volume_mesh::VolumeMeshData,
1025        clip_planes: Vec<[f32; 4]>,
1026    ) -> crate::resources::JobId {
1027        let slot = crate::resources::ResultSlot::<(
1028            crate::resources::mesh::mesh_store::MeshId,
1029            Vec<u32>,
1030        )>::new();
1031        let slot_for_apply = slot.clone();
1032        let device_for_apply = device.clone();
1033
1034        let id = {
1035            let mut runner = self.jobs.lock().expect("upload job runner poisoned");
1036            runner.submit_cpu(move |progress| {
1037                progress.set(0.1);
1038                let (mesh_data, face_to_cell) =
1039                    crate::resources::volume::volume_mesh::extract_clipped_volume_faces(
1040                        &data,
1041                        &clip_planes,
1042                    );
1043                progress.set(0.5);
1044                DeviceResources::validate_mesh_data(&mesh_data)?;
1045                let prep = DeviceResources::prep_mesh_data(&mesh_data);
1046                progress.set(0.95);
1047                Ok(crate::resources::upload_jobs::JobProduct::with_apply(
1048                    Box::new(move |resources: &mut DeviceResources| {
1049                        let mesh_id =
1050                            resources.assemble_mesh_data(&device_for_apply, &mesh_data, prep);
1051                        slot_for_apply.set((mesh_id, face_to_cell));
1052                    }),
1053                ))
1054            })
1055        };
1056
1057        self.job_results
1058            .clipped_volume_mesh
1059            .lock()
1060            .expect("clipped volume mesh result map poisoned")
1061            .insert(id, slot);
1062        id
1063    }
1064
1065    /// Take the [`VolumeMeshItem`](crate::VolumeMeshItem) produced
1066    /// by a completed
1067    /// [`begin_upload_clipped_volume_mesh`](Self::begin_upload_clipped_volume_mesh) job.
1068    pub fn upload_result_clipped_volume_mesh(
1069        &mut self,
1070        id: crate::resources::JobId,
1071    ) -> crate::error::ViewportResult<crate::VolumeMeshItem> {
1072        let mut map = self
1073            .job_results
1074            .clipped_volume_mesh
1075            .lock()
1076            .expect("clipped volume mesh result map poisoned");
1077        let slot = match map.get(&id) {
1078            Some(s) => s.clone(),
1079            None => {
1080                return Err(crate::error::ViewportError::JobResultMissing {
1081                    reason: "unknown id or wrong upload type",
1082                });
1083            }
1084        };
1085        match slot.take() {
1086            Some((mesh_id, face_to_cell)) => {
1087                map.remove(&id);
1088                Ok(crate::VolumeMeshItem::new(mesh_id, face_to_cell))
1089            }
1090            None => Err(crate::error::ViewportError::JobNotReady),
1091        }
1092    }
1093
1094    /// Start an asynchronous sparse voxel grid upload.
1095    pub fn begin_upload_sparse_volume_grid_data(
1096        &mut self,
1097        device: &wgpu::Device,
1098        data: crate::resources::volume::sparse_volume::SparseVolumeGridData,
1099    ) -> crate::resources::JobId {
1100        let slot =
1101            crate::resources::ResultSlot::<crate::resources::mesh::mesh_store::MeshId>::new();
1102        let slot_for_apply = slot.clone();
1103        let device_for_apply = device.clone();
1104
1105        let id = {
1106            let mut runner = self.jobs.lock().expect("upload job runner poisoned");
1107            runner.submit_cpu(move |progress| {
1108                progress.set(0.1);
1109                let mesh_data =
1110                    crate::resources::volume::sparse_volume::extract_sparse_boundary(&data);
1111                progress.set(0.5);
1112                DeviceResources::validate_mesh_data(&mesh_data)?;
1113                let prep = DeviceResources::prep_mesh_data(&mesh_data);
1114                progress.set(0.95);
1115                Ok(crate::resources::upload_jobs::JobProduct::with_apply(
1116                    Box::new(move |resources: &mut DeviceResources| {
1117                        let mesh_id =
1118                            resources.assemble_mesh_data(&device_for_apply, &mesh_data, prep);
1119                        slot_for_apply.set(mesh_id);
1120                    }),
1121                ))
1122            })
1123        };
1124
1125        self.job_results
1126            .sparse_volume_grid
1127            .lock()
1128            .expect("sparse volume grid result map poisoned")
1129            .insert(id, slot);
1130        id
1131    }
1132
1133    /// Take the [`MeshId`](crate::resources::mesh::mesh_store::MeshId) produced by a completed
1134    /// [`begin_upload_sparse_volume_grid_data`](Self::begin_upload_sparse_volume_grid_data)
1135    /// job.
1136    pub fn upload_result_sparse_volume_grid(
1137        &mut self,
1138        id: crate::resources::JobId,
1139    ) -> crate::error::ViewportResult<crate::resources::mesh::mesh_store::MeshId> {
1140        let mut map = self
1141            .job_results
1142            .sparse_volume_grid
1143            .lock()
1144            .expect("sparse volume grid result map poisoned");
1145        let slot = match map.get(&id) {
1146            Some(s) => s.clone(),
1147            None => {
1148                return Err(crate::error::ViewportError::JobResultMissing {
1149                    reason: "unknown id or wrong upload type",
1150                });
1151            }
1152        };
1153        match slot.take() {
1154            Some(mesh_id) => {
1155                map.remove(&id);
1156                Ok(mesh_id)
1157            }
1158            None => Err(crate::error::ViewportError::JobNotReady),
1159        }
1160    }
1161
1162    /// Upload per-vertex, per-cell, per-face scalar, and per-face colour attributes to GPU buffers.
1163    ///
1164    /// Returns `(attribute_buffers, attribute_ranges, face_vertex_buffer, face_attribute_buffers,
1165    /// face_colour_buffers)`.
1166    ///
1167    /// - `attribute_buffers`: per-vertex storage buffers for `Vertex` and `Cell` kinds.
1168    /// - `attribute_ranges`: `(min, max)` per attribute name (all scalar kinds).
1169    /// - `face_vertex_buffer`: non-indexed 3N-vertex buffer (built once if any `Face`/`FaceColour` attr exists).
1170    /// - `face_attribute_buffers`: per-face scalar storage buffers (3N `f32` entries, replicated).
1171    /// - `face_colour_buffers`: per-face colour storage buffers (3N `[f32;4]` entries, replicated).
1172    fn upload_attributes(
1173        device: &wgpu::Device,
1174        attributes: &std::collections::HashMap<String, AttributeData>,
1175        positions: &[[f32; 3]],
1176        normals: &[[f32; 3]],
1177        indices: &[u32],
1178        uvs: Option<&[[f32; 2]]>,
1179        tangents: Option<&[[f32; 4]]>,
1180    ) -> (
1181        std::collections::HashMap<String, wgpu::Buffer>,
1182        std::collections::HashMap<String, (f32, f32)>,
1183        Option<wgpu::Buffer>,
1184        std::collections::HashMap<String, wgpu::Buffer>,
1185        std::collections::HashMap<String, wgpu::Buffer>,
1186        std::collections::HashMap<String, wgpu::Buffer>,
1187    ) {
1188        let mut bufs = std::collections::HashMap::new();
1189        let mut ranges = std::collections::HashMap::new();
1190        let mut face_attr_bufs: std::collections::HashMap<String, wgpu::Buffer> =
1191            std::collections::HashMap::new();
1192        let mut vector_attr_bufs: std::collections::HashMap<String, wgpu::Buffer> =
1193            std::collections::HashMap::new();
1194        let mut face_colour_bufs: std::collections::HashMap<String, wgpu::Buffer> =
1195            std::collections::HashMap::new();
1196        let mut face_vbuf: Option<wgpu::Buffer> = None;
1197
1198        let n_tris = indices.len() / 3;
1199
1200        for (name, attr_data) in attributes {
1201            match attr_data {
1202                AttributeData::Vertex(v) => {
1203                    let scalars = v.clone();
1204                    if scalars.is_empty() {
1205                        continue;
1206                    }
1207                    let min = scalars.iter().cloned().fold(f32::INFINITY, f32::min);
1208                    let max = scalars.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
1209                    let buf =
1210                        Self::create_storage_buffer_f32(device, &format!("attr_{name}"), &scalars);
1211                    bufs.insert(name.clone(), buf);
1212                    ranges.insert(name.clone(), (min, max));
1213                }
1214                AttributeData::Cell(c) => {
1215                    let scalars = Self::expand_cell_to_vertex(c, positions, indices);
1216                    if scalars.is_empty() {
1217                        continue;
1218                    }
1219                    let min = scalars.iter().cloned().fold(f32::INFINITY, f32::min);
1220                    let max = scalars.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
1221                    let buf =
1222                        Self::create_storage_buffer_f32(device, &format!("attr_{name}"), &scalars);
1223                    bufs.insert(name.clone(), buf);
1224                    ranges.insert(name.clone(), (min, max));
1225                }
1226                AttributeData::Face(f) => {
1227                    // Build the shared face vertex buffer on first Face/FaceColour attribute.
1228                    if face_vbuf.is_none() {
1229                        face_vbuf = Some(Self::build_face_vertex_buffer(
1230                            device, positions, normals, indices, uvs, tangents,
1231                        ));
1232                    }
1233                    let expanded = Self::expand_face_scalars_to_3n(f, n_tris);
1234                    if expanded.is_empty() {
1235                        continue;
1236                    }
1237                    let min = expanded.iter().cloned().fold(f32::INFINITY, f32::min);
1238                    let max = expanded.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
1239                    let buf = Self::create_storage_buffer_f32(
1240                        device,
1241                        &format!("face_attr_{name}"),
1242                        &expanded,
1243                    );
1244                    face_attr_bufs.insert(name.clone(), buf);
1245                    ranges.insert(name.clone(), (min, max));
1246                }
1247                AttributeData::FaceColour(colours) => {
1248                    // Build the shared face vertex buffer on first Face/FaceColour attribute.
1249                    if face_vbuf.is_none() {
1250                        face_vbuf = Some(Self::build_face_vertex_buffer(
1251                            device, positions, normals, indices, uvs, tangents,
1252                        ));
1253                    }
1254                    let expanded = Self::expand_face_colours_to_3n(colours, n_tris);
1255                    if expanded.is_empty() {
1256                        continue;
1257                    }
1258                    let byte_len = std::mem::size_of::<[f32; 4]>() * expanded.len();
1259                    let buf = device.create_buffer(&wgpu::BufferDescriptor {
1260                        label: Some(&format!("face_colour_{name}")),
1261                        size: byte_len as u64,
1262                        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
1263                        mapped_at_creation: true,
1264                    });
1265                    {
1266                        let mut view = buf.slice(..).get_mapped_range_mut();
1267                        view.copy_from_slice(bytemuck::cast_slice(&expanded));
1268                    }
1269                    buf.unmap();
1270                    face_colour_bufs.insert(name.clone(), buf);
1271                }
1272                AttributeData::Edge(e) => {
1273                    // Average edge values to vertex values (each edge's scalar is
1274                    // distributed to its two endpoint vertices).
1275                    let scalars = Self::expand_edge_to_vertex(e, positions, indices);
1276                    if scalars.is_empty() {
1277                        continue;
1278                    }
1279                    let min = scalars.iter().cloned().fold(f32::INFINITY, f32::min);
1280                    let max = scalars.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
1281                    let buf =
1282                        Self::create_storage_buffer_f32(device, &format!("attr_{name}"), &scalars);
1283                    bufs.insert(name.clone(), buf);
1284                    ranges.insert(name.clone(), (min, max));
1285                }
1286                AttributeData::Halfedge(h) | AttributeData::Corner(h) => {
1287                    // Per-corner scalars: already 3*n_tris values (one per corner),
1288                    // matching the face vertex buffer layout. Store directly.
1289                    if face_vbuf.is_none() {
1290                        face_vbuf = Some(Self::build_face_vertex_buffer(
1291                            device, positions, normals, indices, uvs, tangents,
1292                        ));
1293                    }
1294                    if h.is_empty() {
1295                        continue;
1296                    }
1297                    let expanded = h.as_slice();
1298                    let min = expanded.iter().cloned().fold(f32::INFINITY, f32::min);
1299                    let max = expanded.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
1300                    let buf = Self::create_storage_buffer_f32(
1301                        device,
1302                        &format!("face_attr_{name}"),
1303                        &expanded,
1304                    );
1305                    face_attr_bufs.insert(name.clone(), buf);
1306                    ranges.insert(name.clone(), (min, max));
1307                }
1308                AttributeData::VertexVector(v) => {
1309                    // Flatten [f32; 3] -> [f32] with 12-byte per-vertex stride.
1310                    // Bound as vertex buffer 1 in the LIC surface pass (location 1).
1311                    if v.is_empty() {
1312                        continue;
1313                    }
1314                    let flat: Vec<f32> = v.iter().flat_map(|&[x, y, z]| [x, y, z]).collect();
1315                    let byte_len = (std::mem::size_of::<f32>() * flat.len()) as u64;
1316                    let buf = device.create_buffer(&wgpu::BufferDescriptor {
1317                        label: Some(&format!("vec_attr_{name}")),
1318                        size: byte_len,
1319                        usage: wgpu::BufferUsages::VERTEX
1320                            | wgpu::BufferUsages::STORAGE
1321                            | wgpu::BufferUsages::COPY_DST,
1322                        mapped_at_creation: true,
1323                    });
1324                    {
1325                        let mut view = buf.slice(..).get_mapped_range_mut();
1326                        view.copy_from_slice(bytemuck::cast_slice(&flat));
1327                    }
1328                    buf.unmap();
1329                    vector_attr_bufs.insert(name.clone(), buf);
1330                }
1331            }
1332        }
1333        (
1334            bufs,
1335            ranges,
1336            face_vbuf,
1337            face_attr_bufs,
1338            face_colour_bufs,
1339            vector_attr_bufs,
1340        )
1341    }
1342
1343    /// Allocate and fill a STORAGE buffer from a slice of `f32` values.
1344    fn create_storage_buffer_f32(device: &wgpu::Device, label: &str, data: &[f32]) -> wgpu::Buffer {
1345        let buf = device.create_buffer(&wgpu::BufferDescriptor {
1346            label: Some(label),
1347            size: (std::mem::size_of::<f32>() * data.len()) as u64,
1348            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
1349            mapped_at_creation: true,
1350        });
1351        {
1352            let mut view = buf.slice(..).get_mapped_range_mut();
1353            view.copy_from_slice(bytemuck::cast_slice(data));
1354        }
1355        buf.unmap();
1356        buf
1357    }
1358
1359    /// Build a non-indexed 3N-vertex buffer: one vertex per triangle corner, geometry only.
1360    fn build_face_vertex_buffer(
1361        device: &wgpu::Device,
1362        positions: &[[f32; 3]],
1363        normals: &[[f32; 3]],
1364        indices: &[u32],
1365        uvs: Option<&[[f32; 2]]>,
1366        tangents: Option<&[[f32; 4]]>,
1367    ) -> wgpu::Buffer {
1368        let n_tris = indices.len() / 3;
1369        let mut verts: Vec<Vertex> = Vec::with_capacity(n_tris * 3);
1370        for tri in indices.chunks(3) {
1371            for &vi in tri {
1372                let vi = vi as usize;
1373                let uv = uvs.and_then(|u| u.get(vi)).copied().unwrap_or([0.0, 0.0]);
1374                let tangent = tangents
1375                    .and_then(|t| t.get(vi))
1376                    .copied()
1377                    .unwrap_or([0.0, 0.0, 0.0, 1.0]);
1378                verts.push(Vertex {
1379                    position: positions.get(vi).copied().unwrap_or([0.0, 0.0, 0.0]),
1380                    normal: normals.get(vi).copied().unwrap_or([0.0, 1.0, 0.0]),
1381                    colour: [1.0, 1.0, 1.0, 1.0],
1382                    uv,
1383                    tangent,
1384                });
1385            }
1386        }
1387        let buf = device.create_buffer(&wgpu::BufferDescriptor {
1388            label: Some("face_vertex_buf"),
1389            size: (std::mem::size_of::<Vertex>() * verts.len().max(1)) as u64,
1390            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1391            mapped_at_creation: true,
1392        });
1393        {
1394            let mut view = buf.slice(..).get_mapped_range_mut();
1395            view.copy_from_slice(bytemuck::cast_slice(&verts));
1396        }
1397        buf.unmap();
1398        buf
1399    }
1400
1401    /// Expand N face scalar values to 3N by repeating each value three times.
1402    fn expand_face_scalars_to_3n(values: &[f32], n_tris: usize) -> Vec<f32> {
1403        let mut out = Vec::with_capacity(n_tris * 3);
1404        for i in 0..n_tris {
1405            let v = values.get(i).copied().unwrap_or(0.0);
1406            out.push(v);
1407            out.push(v);
1408            out.push(v);
1409        }
1410        out
1411    }
1412
1413    /// Expand N face RGBA colours to 3N by repeating each colour three times.
1414    fn expand_face_colours_to_3n(colours: &[[f32; 4]], n_tris: usize) -> Vec<[f32; 4]> {
1415        let mut out = Vec::with_capacity(n_tris * 3);
1416        for i in 0..n_tris {
1417            let c = colours.get(i).copied().unwrap_or([1.0, 1.0, 1.0, 1.0]);
1418            out.push(c);
1419            out.push(c);
1420            out.push(c);
1421        }
1422        out
1423    }
1424
1425    /// Expand per-directed-edge scalars to per-vertex by averaging over incident edges.
1426    ///
1427    /// Edge ordering: `edge_values[3*t + k]` is the k-th edge of triangle `t`,
1428    /// running from vertex `k` to vertex `(k+1)%3` of that triangle.
1429    /// Each edge's value is added to both endpoint vertices; the final per-vertex
1430    /// value is the average over all incident edge contributions.
1431    fn expand_edge_to_vertex(
1432        edge_values: &[f32],
1433        positions: &[[f32; 3]],
1434        indices: &[u32],
1435    ) -> Vec<f32> {
1436        let n = positions.len();
1437        let mut sum = vec![0.0f32; n];
1438        let mut count = vec![0u32; n];
1439        for (tri_idx, chunk) in indices.chunks(3).enumerate() {
1440            for k in 0..3 {
1441                let v = edge_values.get(3 * tri_idx + k).copied().unwrap_or(0.0);
1442                let vi0 = chunk[k] as usize;
1443                let vi1 = chunk[(k + 1) % 3] as usize;
1444                if vi0 < n {
1445                    sum[vi0] += v;
1446                    count[vi0] += 1;
1447                }
1448                if vi1 < n {
1449                    sum[vi1] += v;
1450                    count[vi1] += 1;
1451                }
1452            }
1453        }
1454        (0..n)
1455            .map(|i| {
1456                if count[i] > 0 {
1457                    sum[i] / count[i] as f32
1458                } else {
1459                    0.0
1460                }
1461            })
1462            .collect()
1463    }
1464
1465    /// Expand per-cell (per-triangle) scalar values to per-vertex by averaging contributions.
1466    fn expand_cell_to_vertex(
1467        cell_values: &[f32],
1468        positions: &[[f32; 3]],
1469        indices: &[u32],
1470    ) -> Vec<f32> {
1471        let n = positions.len();
1472        let mut sum = vec![0.0f32; n];
1473        let mut count = vec![0u32; n];
1474        for (tri_idx, chunk) in indices.chunks(3).enumerate() {
1475            let v = cell_values.get(tri_idx).copied().unwrap_or(0.0);
1476            for &vi in chunk {
1477                let vi = vi as usize;
1478                if vi < n {
1479                    sum[vi] += v;
1480                    count[vi] += 1;
1481                }
1482            }
1483        }
1484        (0..n)
1485            .map(|i| {
1486                if count[i] > 0 {
1487                    sum[i] / count[i] as f32
1488                } else {
1489                    0.0
1490                }
1491            })
1492            .collect()
1493    }
1494
1495    /// Compute per-vertex tangents using Gram-Schmidt orthogonalization with handedness.
1496    ///
1497    /// Returns a `Vec<[f32; 4]>` of length `positions.len()` where each element is
1498    /// `[tx, ty, tz, w]` with `w = +/-1.0` encoding bitangent handedness.
1499    ///
1500    /// Requires triangulated indices (every 3 indices = one triangle).
1501    /// If any triangle is degenerate (zero-area or zero UV area), its contribution is skipped.
1502    fn compute_tangents(
1503        positions: &[[f32; 3]],
1504        normals: &[[f32; 3]],
1505        uvs: &[[f32; 2]],
1506        indices: &[u32],
1507    ) -> Vec<[f32; 4]> {
1508        let n = positions.len();
1509        let tri_count = indices.len() / 3;
1510
1511        // Accumulate sdir/tdir contributions per vertex. Sequential.
1512        //
1513        // **Do not** use rayon parallel iterators in this function. This
1514        // routine is already invoked from a rayon worker : every mesh
1515        // upload runs `prep_mesh_data -> compute_tangents` inside a
1516        // `submit_cpu` job (see `upload_jobs::Runner::submit_cpu`).
1517        // Adding intra-mesh parallelism causes nested rayon work:
1518        // a worker enters `par_chunks(3).fold(...)`, parks at a join,
1519        // steals another mesh's upload (which itself enters compute_tangents
1520        // and parks again), and so on. Each suspension keeps frames on
1521        // the worker's 2 MB stack; with the upload queue draining
1522        // concurrent tangent tasks, stack depth grows unboundedly and
1523        // overflows.
1524        //
1525        // The function is cache-friendly and runs at ~30 ns / triangle
1526        // sequentially (~ 15 ms for a 500 k-tri mesh). Per-mesh
1527        // parallelism comes from the upload job pool, not from inside
1528        // this function.
1529        let mut tan1 = vec![[0.0f32; 3]; n];
1530        let mut tan2 = vec![[0.0f32; 3]; n];
1531        for t in 0..tri_count {
1532            let i0 = indices[t * 3] as usize;
1533            let i1 = indices[t * 3 + 1] as usize;
1534            let i2 = indices[t * 3 + 2] as usize;
1535
1536            let p0 = positions[i0];
1537            let p1 = positions[i1];
1538            let p2 = positions[i2];
1539            let uv0 = uvs[i0];
1540            let uv1 = uvs[i1];
1541            let uv2 = uvs[i2];
1542
1543            let e1 = [p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]];
1544            let e2 = [p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2]];
1545            let du1 = uv1[0] - uv0[0];
1546            let dv1 = uv1[1] - uv0[1];
1547            let du2 = uv2[0] - uv0[0];
1548            let dv2 = uv2[1] - uv0[1];
1549
1550            let det = du1 * dv2 - du2 * dv1;
1551            if det.abs() < 1e-10 {
1552                continue;
1553            }
1554            let r = 1.0 / det;
1555
1556            let sdir = [
1557                (dv2 * e1[0] - dv1 * e2[0]) * r,
1558                (dv2 * e1[1] - dv1 * e2[1]) * r,
1559                (dv2 * e1[2] - dv1 * e2[2]) * r,
1560            ];
1561            let tdir = [
1562                (du1 * e2[0] - du2 * e1[0]) * r,
1563                (du1 * e2[1] - du2 * e1[1]) * r,
1564                (du1 * e2[2] - du2 * e1[2]) * r,
1565            ];
1566
1567            for &vi in &[i0, i1, i2] {
1568                for k in 0..3 {
1569                    tan1[vi][k] += sdir[k];
1570                    tan2[vi][k] += tdir[k];
1571                }
1572            }
1573        }
1574
1575        // Gram-Schmidt orthogonalization per vertex. Sequential, for the
1576        // same nested-rayon reason as above.
1577        (0..n)
1578            .map(|i| {
1579                let n_v = normals[i];
1580                let t = tan1[i];
1581                let dot = n_v[0] * t[0] + n_v[1] * t[1] + n_v[2] * t[2];
1582                let tx = t[0] - n_v[0] * dot;
1583                let ty = t[1] - n_v[1] * dot;
1584                let tz = t[2] - n_v[2] * dot;
1585                let len = (tx * tx + ty * ty + tz * tz).sqrt();
1586                let (tx, ty, tz) = if len > 1e-7 {
1587                    (tx / len, ty / len, tz / len)
1588                } else {
1589                    (1.0, 0.0, 0.0)
1590                };
1591                let cx = n_v[1] * tz - n_v[2] * ty;
1592                let cy = n_v[2] * tx - n_v[0] * tz;
1593                let cz = n_v[0] * ty - n_v[1] * tx;
1594                let w = if cx * tan2[i][0] + cy * tan2[i][1] + cz * tan2[i][2] < 0.0 {
1595                    -1.0
1596                } else {
1597                    1.0
1598                };
1599                [tx, ty, tz, w]
1600            })
1601            .collect()
1602    }
1603
1604    /// Validate mesh data before upload.
1605    fn validate_mesh_data(data: &MeshData) -> crate::error::ViewportResult<()> {
1606        if data.positions.is_empty() || data.indices.is_empty() {
1607            return Err(crate::error::ViewportError::EmptyMesh {
1608                positions: data.positions.len(),
1609                indices: data.indices.len(),
1610            });
1611        }
1612        if data.positions.len() != data.normals.len() {
1613            return Err(crate::error::ViewportError::MeshLengthMismatch {
1614                positions: data.positions.len(),
1615                normals: data.normals.len(),
1616            });
1617        }
1618        let vertex_count = data.positions.len();
1619        for &idx in &data.indices {
1620            if (idx as usize) >= vertex_count {
1621                return Err(crate::error::ViewportError::InvalidVertexIndex {
1622                    vertex_index: idx,
1623                    vertex_count,
1624                });
1625            }
1626        }
1627        Ok(())
1628    }
1629
1630    /// Build per-vertex normal visualization lines from mesh data.
1631    fn build_normal_lines(data: &MeshData) -> Vec<Vertex> {
1632        let normal_colour = [0.627_f32, 0.769, 1.0, 1.0];
1633        let normal_length = 0.1_f32;
1634        let mut normal_line_verts: Vec<Vertex> = Vec::with_capacity(data.positions.len() * 2);
1635        for (p, n) in data.positions.iter().zip(data.normals.iter()) {
1636            let tip = [
1637                p[0] + n[0] * normal_length,
1638                p[1] + n[1] * normal_length,
1639                p[2] + n[2] * normal_length,
1640            ];
1641            normal_line_verts.push(Vertex {
1642                position: *p,
1643                normal: *n,
1644                colour: normal_colour,
1645                uv: [0.0, 0.0],
1646                tangent: [0.0, 0.0, 0.0, 1.0],
1647            });
1648            normal_line_verts.push(Vertex {
1649                position: tip,
1650                normal: *n,
1651                colour: normal_colour,
1652                uv: [0.0, 0.0],
1653                tangent: [0.0, 0.0, 0.0, 1.0],
1654            });
1655        }
1656        normal_line_verts
1657    }
1658
1659    pub(crate) fn create_mesh(
1660        device: &wgpu::Device,
1661        object_bgl: &wgpu::BindGroupLayout,
1662        fallback_albedo_view: &wgpu::TextureView,
1663        fallback_normal_view: &wgpu::TextureView,
1664        fallback_ao_view: &wgpu::TextureView,
1665        fallback_sampler: &wgpu::Sampler,
1666        lut_sampler: &wgpu::Sampler,
1667        fallback_lut_view: &wgpu::TextureView,
1668        fallback_scalar_buf: &wgpu::Buffer,
1669        fallback_matcap_view: &wgpu::TextureView,
1670        fallback_face_colour_buf: &wgpu::Buffer,
1671        fallback_warp_buf: &wgpu::Buffer,
1672        fallback_position_override_buf: &wgpu::Buffer,
1673        fallback_normal_override_buf: &wgpu::Buffer,
1674        fallback_metallic_roughness_view: &wgpu::TextureView,
1675        fallback_emissive_view: &wgpu::TextureView,
1676        vertices: &[Vertex],
1677        indices: &[u32],
1678    ) -> GpuMesh {
1679        Self::create_mesh_with_normals(
1680            device,
1681            object_bgl,
1682            fallback_albedo_view,
1683            fallback_normal_view,
1684            fallback_ao_view,
1685            fallback_sampler,
1686            lut_sampler,
1687            fallback_lut_view,
1688            fallback_scalar_buf,
1689            fallback_matcap_view,
1690            fallback_face_colour_buf,
1691            fallback_warp_buf,
1692            fallback_position_override_buf,
1693            fallback_normal_override_buf,
1694            fallback_metallic_roughness_view,
1695            fallback_emissive_view,
1696            vertices,
1697            indices,
1698            None,
1699        )
1700    }
1701
1702    pub(crate) fn create_mesh_with_normals(
1703        device: &wgpu::Device,
1704        object_bgl: &wgpu::BindGroupLayout,
1705        fallback_albedo_view: &wgpu::TextureView,
1706        fallback_normal_view: &wgpu::TextureView,
1707        fallback_ao_view: &wgpu::TextureView,
1708        fallback_sampler: &wgpu::Sampler,
1709        lut_sampler: &wgpu::Sampler,
1710        fallback_lut_view: &wgpu::TextureView,
1711        fallback_scalar_buf: &wgpu::Buffer,
1712        fallback_matcap_view: &wgpu::TextureView,
1713        fallback_face_colour_buf: &wgpu::Buffer,
1714        fallback_warp_buf: &wgpu::Buffer,
1715        fallback_position_override_buf: &wgpu::Buffer,
1716        fallback_normal_override_buf: &wgpu::Buffer,
1717        fallback_metallic_roughness_view: &wgpu::TextureView,
1718        fallback_emissive_view: &wgpu::TextureView,
1719        vertices: &[Vertex],
1720        indices: &[u32],
1721        normal_line_verts: Option<&[Vertex]>,
1722    ) -> GpuMesh {
1723        use bytemuck::cast_slice;
1724        use wgpu;
1725
1726        let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1727            label: Some("vertex_buf"),
1728            size: (std::mem::size_of::<Vertex>() * vertices.len()) as u64,
1729            usage: wgpu::BufferUsages::VERTEX
1730                | wgpu::BufferUsages::COPY_DST
1731                | wgpu::BufferUsages::STORAGE,
1732            mapped_at_creation: true,
1733        });
1734        vertex_buffer
1735            .slice(..)
1736            .get_mapped_range_mut()
1737            .copy_from_slice(cast_slice(vertices));
1738        vertex_buffer.unmap();
1739
1740        let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1741            label: Some("index_buf"),
1742            size: (std::mem::size_of::<u32>() * indices.len()) as u64,
1743            usage: wgpu::BufferUsages::INDEX
1744                | wgpu::BufferUsages::COPY_DST
1745                | wgpu::BufferUsages::STORAGE,
1746            mapped_at_creation: true,
1747        });
1748        index_buffer
1749            .slice(..)
1750            .get_mapped_range_mut()
1751            .copy_from_slice(cast_slice(indices));
1752        index_buffer.unmap();
1753
1754        let edge_indices = generate_edge_indices(indices);
1755        let edge_buf_size = (std::mem::size_of::<u32>() * edge_indices.len().max(2)) as u64;
1756        let edge_index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1757            label: Some("edge_index_buf"),
1758            size: edge_buf_size,
1759            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
1760            mapped_at_creation: true,
1761        });
1762        {
1763            let mut mapped = edge_index_buffer.slice(..).get_mapped_range_mut();
1764            let edge_bytes = cast_slice::<u32, u8>(&edge_indices);
1765            mapped[..edge_bytes.len()].copy_from_slice(edge_bytes);
1766        }
1767        edge_index_buffer.unmap();
1768
1769        let identity = glam::Mat4::IDENTITY.to_cols_array_2d();
1770        let object_uniform = ObjectUniform {
1771            model: identity,
1772            colour: [1.0, 1.0, 1.0, 1.0],
1773            selected: 0,
1774            wireframe: 0,
1775            ambient: 0.15,
1776            diffuse: 0.75,
1777            specular: 0.4,
1778            shininess: 32.0,
1779            has_texture: 0,
1780            use_pbr: 0,
1781            metallic: 0.0,
1782            roughness: 0.5,
1783            has_normal_map: 0,
1784            has_ao_map: 0,
1785            has_attribute: 0,
1786            scalar_min: 0.0,
1787            scalar_max: 1.0,
1788            receive_shadows: 1,
1789            nan_colour: [0.0, 0.0, 0.0, 0.0],
1790            use_nan_colour: 0,
1791            use_matcap: 0,
1792            matcap_blendable: 0,
1793            unlit: 0,
1794            use_face_colour: 0,
1795            uv_vis_mode: 0,
1796            uv_vis_scale: 8.0,
1797            backface_policy: 0,
1798            backface_colour: [0.0; 4],
1799            has_warp: 0,
1800            warp_scale: 1.0,
1801            has_position_override: 0,
1802            has_normal_override: 0,
1803            emissive: [0.0; 3],
1804            use_flat: 0,
1805            alpha_mode: 0,
1806            alpha_cutoff: 0.5,
1807            has_metallic_roughness_tex: 0,
1808            has_emissive_tex: 0,
1809            uv_transform: [0.0, 0.0, 1.0, 1.0],
1810            deform_flags: 0,
1811            _pad_after_deform: 0,
1812            ao_range: [0.0, 1.0],
1813            metallic_range: [0.0, 1.0],
1814            roughness_range: [0.0, 1.0],
1815        };
1816        let object_uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
1817            label: Some("object_uniform_buf"),
1818            size: std::mem::size_of::<ObjectUniform>() as u64,
1819            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1820            mapped_at_creation: true,
1821        });
1822        object_uniform_buf
1823            .slice(..)
1824            .get_mapped_range_mut()
1825            .copy_from_slice(cast_slice(&[object_uniform]));
1826        object_uniform_buf.unmap();
1827
1828        let object_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1829            label: Some("object_bind_group"),
1830            layout: object_bgl,
1831            entries: &[
1832                wgpu::BindGroupEntry {
1833                    binding: 0,
1834                    resource: object_uniform_buf.as_entire_binding(),
1835                },
1836                wgpu::BindGroupEntry {
1837                    binding: 1,
1838                    resource: wgpu::BindingResource::TextureView(fallback_albedo_view),
1839                },
1840                wgpu::BindGroupEntry {
1841                    binding: 2,
1842                    resource: wgpu::BindingResource::Sampler(fallback_sampler),
1843                },
1844                wgpu::BindGroupEntry {
1845                    binding: 3,
1846                    resource: wgpu::BindingResource::TextureView(fallback_normal_view),
1847                },
1848                wgpu::BindGroupEntry {
1849                    binding: 4,
1850                    resource: wgpu::BindingResource::TextureView(fallback_ao_view),
1851                },
1852                wgpu::BindGroupEntry {
1853                    binding: 5,
1854                    resource: wgpu::BindingResource::TextureView(fallback_lut_view),
1855                },
1856                wgpu::BindGroupEntry {
1857                    binding: 6,
1858                    resource: fallback_scalar_buf.as_entire_binding(),
1859                },
1860                wgpu::BindGroupEntry {
1861                    binding: 7,
1862                    resource: wgpu::BindingResource::TextureView(fallback_matcap_view),
1863                },
1864                wgpu::BindGroupEntry {
1865                    binding: 8,
1866                    resource: fallback_face_colour_buf.as_entire_binding(),
1867                },
1868                wgpu::BindGroupEntry {
1869                    binding: 9,
1870                    resource: fallback_warp_buf.as_entire_binding(),
1871                },
1872                wgpu::BindGroupEntry {
1873                    binding: 10,
1874                    resource: wgpu::BindingResource::Sampler(lut_sampler),
1875                },
1876                wgpu::BindGroupEntry {
1877                    binding: 11,
1878                    resource: wgpu::BindingResource::TextureView(fallback_metallic_roughness_view),
1879                },
1880                wgpu::BindGroupEntry {
1881                    binding: 12,
1882                    resource: wgpu::BindingResource::TextureView(fallback_emissive_view),
1883                },
1884                wgpu::BindGroupEntry {
1885                    binding: 13,
1886                    resource: fallback_position_override_buf.as_entire_binding(),
1887                },
1888                wgpu::BindGroupEntry {
1889                    binding: 14,
1890                    resource: fallback_normal_override_buf.as_entire_binding(),
1891                },
1892            ],
1893        });
1894
1895        let normal_override_uniform = ObjectUniform {
1896            model: identity,
1897            colour: [1.0, 1.0, 1.0, 1.0],
1898            selected: 0,
1899            wireframe: 0,
1900            ambient: 0.15,
1901            diffuse: 0.75,
1902            specular: 0.4,
1903            shininess: 32.0,
1904            has_texture: 0,
1905            use_pbr: 0,
1906            metallic: 0.0,
1907            roughness: 0.5,
1908            has_normal_map: 0,
1909            has_ao_map: 0,
1910            has_attribute: 0,
1911            scalar_min: 0.0,
1912            scalar_max: 1.0,
1913            receive_shadows: 1,
1914            nan_colour: [0.0, 0.0, 0.0, 0.0],
1915            use_nan_colour: 0,
1916            use_matcap: 0,
1917            matcap_blendable: 0,
1918            unlit: 0,
1919            use_face_colour: 0,
1920            uv_vis_mode: 0,
1921            uv_vis_scale: 8.0,
1922            backface_policy: 0,
1923            backface_colour: [0.0; 4],
1924            has_warp: 0,
1925            warp_scale: 1.0,
1926            has_position_override: 0,
1927            has_normal_override: 0,
1928            emissive: [0.0; 3],
1929            use_flat: 0,
1930            alpha_mode: 0,
1931            alpha_cutoff: 0.5,
1932            has_metallic_roughness_tex: 0,
1933            has_emissive_tex: 0,
1934            uv_transform: [0.0, 0.0, 1.0, 1.0],
1935            deform_flags: 0,
1936            _pad_after_deform: 0,
1937            ao_range: [0.0, 1.0],
1938            metallic_range: [0.0, 1.0],
1939            roughness_range: [0.0, 1.0],
1940        };
1941        let normal_uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
1942            label: Some("normal_uniform_buf"),
1943            size: std::mem::size_of::<ObjectUniform>() as u64,
1944            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1945            mapped_at_creation: true,
1946        });
1947        normal_uniform_buf
1948            .slice(..)
1949            .get_mapped_range_mut()
1950            .copy_from_slice(cast_slice(&[normal_override_uniform]));
1951        normal_uniform_buf.unmap();
1952
1953        let normal_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1954            label: Some("normal_bind_group"),
1955            layout: object_bgl,
1956            entries: &[
1957                wgpu::BindGroupEntry {
1958                    binding: 0,
1959                    resource: normal_uniform_buf.as_entire_binding(),
1960                },
1961                wgpu::BindGroupEntry {
1962                    binding: 1,
1963                    resource: wgpu::BindingResource::TextureView(fallback_albedo_view),
1964                },
1965                wgpu::BindGroupEntry {
1966                    binding: 2,
1967                    resource: wgpu::BindingResource::Sampler(fallback_sampler),
1968                },
1969                wgpu::BindGroupEntry {
1970                    binding: 3,
1971                    resource: wgpu::BindingResource::TextureView(fallback_normal_view),
1972                },
1973                wgpu::BindGroupEntry {
1974                    binding: 4,
1975                    resource: wgpu::BindingResource::TextureView(fallback_ao_view),
1976                },
1977                wgpu::BindGroupEntry {
1978                    binding: 5,
1979                    resource: wgpu::BindingResource::TextureView(fallback_lut_view),
1980                },
1981                wgpu::BindGroupEntry {
1982                    binding: 6,
1983                    resource: fallback_scalar_buf.as_entire_binding(),
1984                },
1985                wgpu::BindGroupEntry {
1986                    binding: 7,
1987                    resource: wgpu::BindingResource::TextureView(fallback_matcap_view),
1988                },
1989                wgpu::BindGroupEntry {
1990                    binding: 8,
1991                    resource: fallback_face_colour_buf.as_entire_binding(),
1992                },
1993                wgpu::BindGroupEntry {
1994                    binding: 9,
1995                    resource: fallback_warp_buf.as_entire_binding(),
1996                },
1997                wgpu::BindGroupEntry {
1998                    binding: 10,
1999                    resource: wgpu::BindingResource::Sampler(lut_sampler),
2000                },
2001                wgpu::BindGroupEntry {
2002                    binding: 11,
2003                    resource: wgpu::BindingResource::TextureView(fallback_metallic_roughness_view),
2004                },
2005                wgpu::BindGroupEntry {
2006                    binding: 12,
2007                    resource: wgpu::BindingResource::TextureView(fallback_emissive_view),
2008                },
2009                wgpu::BindGroupEntry {
2010                    binding: 13,
2011                    resource: fallback_position_override_buf.as_entire_binding(),
2012                },
2013                wgpu::BindGroupEntry {
2014                    binding: 14,
2015                    resource: fallback_normal_override_buf.as_entire_binding(),
2016                },
2017            ],
2018        });
2019
2020        let (normal_line_buffer, normal_line_count) = if let Some(nl_verts) = normal_line_verts {
2021            if nl_verts.is_empty() {
2022                (None, 0)
2023            } else {
2024                let buf = device.create_buffer(&wgpu::BufferDescriptor {
2025                    label: Some("normal_line_buf"),
2026                    size: (std::mem::size_of::<Vertex>() * nl_verts.len()) as u64,
2027                    usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
2028                    mapped_at_creation: true,
2029                });
2030                buf.slice(..)
2031                    .get_mapped_range_mut()
2032                    .copy_from_slice(cast_slice(nl_verts));
2033                buf.unmap();
2034                let count = nl_verts.len() as u32;
2035                (Some(buf), count)
2036            }
2037        } else {
2038            (None, 0)
2039        };
2040
2041        let aabb = crate::scene::aabb::Aabb::from_positions(
2042            &vertices.iter().map(|v| v.position).collect::<Vec<_>>(),
2043        );
2044
2045        GpuMesh {
2046            vertex_buffer,
2047            index_buffer,
2048            index_count: indices.len() as u32,
2049            edge_index_buffer,
2050            edge_index_count: edge_indices.len() as u32,
2051            normal_line_buffer,
2052            normal_line_count,
2053            object_uniform_buf,
2054            object_bind_group,
2055            last_tex_key: (
2056                u64::MAX,
2057                u64::MAX,
2058                u64::MAX,
2059                u64::MAX,
2060                u64::MAX,
2061                u64::MAX,
2062                u64::MAX,
2063                u64::MAX,
2064                u64::MAX,
2065                0,
2066                0,
2067            ),
2068            normal_uniform_buf,
2069            normal_bind_group,
2070            aabb,
2071            cpu_positions: None,
2072            cpu_indices: None,
2073            attribute_buffers: std::collections::HashMap::new(),
2074            attribute_ranges: std::collections::HashMap::new(),
2075            face_vertex_buffer: None,
2076            face_attribute_buffers: std::collections::HashMap::new(),
2077            face_colour_buffers: std::collections::HashMap::new(),
2078            vector_attribute_buffers: std::collections::HashMap::new(),
2079            position_override_buffer: None,
2080            normal_override_buffer: None,
2081            position_override_gen: 0,
2082            normal_override_gen: 0,
2083        }
2084    }
2085
2086    // ---------------------------------------------------------------------------
2087    // Projected tetrahedra upload
2088    // ---------------------------------------------------------------------------
2089
2090    /// Ensure the projected-tetrahedra bind group layout exists.
2091    ///
2092    /// No-op after the first call. Called internally by
2093    /// [`upload_volume_mesh_with_transparency`](Self::upload_volume_mesh_with_transparency)
2094    /// and [`ensure_pt_pipeline`](Self::ensure_pt_pipeline).
2095    ///
2096    /// Group 1 carries the per-volume uniform and the tet storage buffer.  The
2097    /// colourmap LUT lives in group 2 and is bound per-frame from the renderer's
2098    /// colourmap registry: see [`ensure_pt_lut_bind_group`](Self::ensure_pt_lut_bind_group).
2099    pub(crate) fn ensure_pt_bind_group_layout(&mut self, device: &wgpu::Device) {
2100        if self.pt.bind_group_layout.is_none() {
2101            let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
2102                label: Some("pt_bgl"),
2103                entries: &[
2104                    // binding 0: per-volume uniform (density, scalar_min, scalar_max, thresholds, flags)
2105                    wgpu::BindGroupLayoutEntry {
2106                        binding: 0,
2107                        visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
2108                        ty: wgpu::BindingType::Buffer {
2109                            ty: wgpu::BufferBindingType::Uniform,
2110                            has_dynamic_offset: false,
2111                            min_binding_size: None,
2112                        },
2113                        count: None,
2114                    },
2115                    // binding 1: tet storage buffer (read-only)
2116                    wgpu::BindGroupLayoutEntry {
2117                        binding: 1,
2118                        visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
2119                        ty: wgpu::BindingType::Buffer {
2120                            ty: wgpu::BufferBindingType::Storage { read_only: true },
2121                            has_dynamic_offset: false,
2122                            min_binding_size: None,
2123                        },
2124                        count: None,
2125                    },
2126                ],
2127            });
2128            self.pt.bind_group_layout = Some(bgl);
2129        }
2130        if self.pt.lut_bind_group_layout.is_none() {
2131            // binding 0: colourmap texture (256x1 D2), binding 1: linear-clamp sampler.
2132            let bgl = crate::resources::builders::texture_sampler_bgl(
2133                device,
2134                "pt_lut_bgl",
2135                wgpu::ShaderStages::FRAGMENT,
2136            );
2137            self.pt.lut_bind_group_layout = Some(bgl);
2138        }
2139    }
2140
2141    /// Build (and cache) the projected-tet LUT bind group for `colourmap_id`.
2142    ///
2143    /// Returns the bind group keyed by `colourmap_id.0`. Falls back to the
2144    /// fallback LUT (and its dedicated cached bind group) when the slot is empty.
2145    /// Callers must ensure the bind group layouts exist
2146    /// ([`ensure_pt_bind_group_layout`](Self::ensure_pt_bind_group_layout)).
2147    pub(crate) fn ensure_pt_lut_bind_group(
2148        &mut self,
2149        device: &wgpu::Device,
2150        colourmap_id: Option<crate::resources::ColourmapId>,
2151    ) -> &wgpu::BindGroup {
2152        let bgl = self
2153            .pt
2154            .lut_bind_group_layout
2155            .as_ref()
2156            .expect("pt_lut_bind_group_layout must exist");
2157        let sampler = &self.material_sampler;
2158
2159        match colourmap_id.and_then(|id| self.content.colourmap_views.get(id.0).map(|_| id.0)) {
2160            Some(slot) => {
2161                if !self.pt.lut_bind_groups.contains_key(&slot) {
2162                    let lut_view = &self.content.colourmap_views[slot];
2163                    let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
2164                        label: Some("pt_lut_bind_group"),
2165                        layout: bgl,
2166                        entries: &[
2167                            wgpu::BindGroupEntry {
2168                                binding: 0,
2169                                resource: wgpu::BindingResource::TextureView(lut_view),
2170                            },
2171                            wgpu::BindGroupEntry {
2172                                binding: 1,
2173                                resource: wgpu::BindingResource::Sampler(sampler),
2174                            },
2175                        ],
2176                    });
2177                    self.pt.lut_bind_groups.insert(slot, bg);
2178                }
2179                self.pt.lut_bind_groups.get(&slot).unwrap()
2180            }
2181            None => {
2182                if self.pt.fallback_lut_bind_group.is_none() {
2183                    let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
2184                        label: Some("pt_fallback_lut_bind_group"),
2185                        layout: bgl,
2186                        entries: &[
2187                            wgpu::BindGroupEntry {
2188                                binding: 0,
2189                                resource: wgpu::BindingResource::TextureView(
2190                                    &self.content.fallback_lut_view,
2191                                ),
2192                            },
2193                            wgpu::BindGroupEntry {
2194                                binding: 1,
2195                                resource: wgpu::BindingResource::Sampler(sampler),
2196                            },
2197                        ],
2198                    });
2199                    self.pt.fallback_lut_bind_group = Some(bg);
2200                }
2201                self.pt.fallback_lut_bind_group.as_ref().unwrap()
2202            }
2203        }
2204    }
2205
2206    /// Decompose all cells in `data` into tetrahedra and upload to the GPU.
2207    ///
2208    /// `scalar_attribute` names a key in `data.cell_scalars`; cells without the attribute
2209    /// get scalar 0.0.  The scalar range is auto-detected from the data.
2210    ///
2211    /// Returns a [`ProjectedTetId`] that can be placed in a
2212    /// [`VolumeMeshItem::projected_tet_id`](crate::renderer::types::VolumeMeshItem::projected_tet_id)
2213    /// each frame to enable the volumetric transparent render mode.
2214    /// Upload a projected-tet mesh and return both the GPU handle and the actual scalar
2215    /// range stored in the GPU buffer. Callers should use the returned scalar range for
2216    /// threshold computations so that brimcast and the GPU always agree on the data range
2217    /// (including the constant-data `scalar_min + 1.0` adjustment in `decompose_into_chunks`).
2218    pub(crate) fn upload_projected_tet(
2219        &mut self,
2220        device: &wgpu::Device,
2221        data: &crate::resources::volume::volume_mesh::VolumeMeshData,
2222        scalar_attribute: &str,
2223    ) -> crate::error::ViewportResult<(ProjectedTetId, f32, f32)> {
2224        self.ensure_pt_bind_group_layout(device);
2225
2226        let (pending, scalar_range, uniform_buffer) =
2227            Self::decompose_into_chunks(device, data, scalar_attribute);
2228
2229        // Build bind groups: one per chunk, all sharing the same uniform buffer.
2230        let chunks = {
2231            let bgl = self
2232                .pt
2233                .bind_group_layout
2234                .as_ref()
2235                .expect("pt_bind_group_layout must exist after ensure_pt_bind_group_layout");
2236            pending
2237                .into_iter()
2238                .map(|(tet_buffer, tet_count)| {
2239                    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
2240                        label: Some("pt_bind_group"),
2241                        layout: bgl,
2242                        entries: &[
2243                            wgpu::BindGroupEntry {
2244                                binding: 0,
2245                                resource: uniform_buffer.as_entire_binding(),
2246                            },
2247                            wgpu::BindGroupEntry {
2248                                binding: 1,
2249                                resource: tet_buffer.as_entire_binding(),
2250                            },
2251                        ],
2252                    });
2253                    crate::resources::types::ProjectedTetChunk {
2254                        tet_buffer,
2255                        tet_count,
2256                        bind_group,
2257                    }
2258                })
2259                .collect::<Vec<_>>()
2260        };
2261
2262        let id = ProjectedTetId(self.content.projected_tet_store.push(GpuProjectedTetMesh {
2263            chunks,
2264            uniform_buffer,
2265            scalar_range,
2266        }));
2267        Ok((id, scalar_range.0, scalar_range.1))
2268    }
2269
2270    /// Start an asynchronous projected-tet mesh upload.
2271    ///
2272    /// Slab decomposition (`decompose_into_chunks`) and the per-chunk tet
2273    /// storage buffers are built on a worker thread on a cloned `Device`.
2274    /// The apply step constructs the per-chunk bind groups against the
2275    /// renderer's existing `pt_bind_group_layout` and colourmap LUT, then
2276    /// inserts the mesh. Returns the [`JobId`](crate::resources::JobId);
2277    /// take the `(ProjectedTetId, scalar_min, scalar_max)` triple via
2278    /// [`upload_result_projected_tet`](Self::upload_result_projected_tet).
2279    #[allow(dead_code)]
2280    pub(crate) fn begin_upload_projected_tet(
2281        &mut self,
2282        device: &wgpu::Device,
2283        data: crate::resources::volume::volume_mesh::VolumeMeshData,
2284        scalar_attribute: String,
2285    ) -> crate::resources::JobId {
2286        // Pipeline layout must exist when the apply step builds bind groups.
2287        self.ensure_pt_bind_group_layout(device);
2288
2289        let slot = crate::resources::ResultSlot::<(ProjectedTetId, f32, f32)>::new();
2290        let slot_for_apply = slot.clone();
2291        let device_for_worker = device.clone();
2292        let device_for_apply = device.clone();
2293
2294        let id = {
2295            let mut runner = self.jobs.lock().expect("upload job runner poisoned");
2296            runner.submit_cpu(move |progress| {
2297                progress.set(0.1);
2298                let (pending, scalar_range, uniform_buffer) =
2299                    DeviceResources::decompose_into_chunks(
2300                        &device_for_worker,
2301                        &data,
2302                        &scalar_attribute,
2303                    );
2304                progress.set(0.95);
2305                Ok(crate::resources::upload_jobs::JobProduct::with_apply(
2306                    Box::new(move |resources: &mut DeviceResources| {
2307                        let chunks = {
2308                            let bgl = resources
2309                                .pt
2310                                .bind_group_layout
2311                                .as_ref()
2312                                .expect("pt_bind_group_layout must exist");
2313                            pending
2314                                .into_iter()
2315                                .map(|(tet_buffer, tet_count)| {
2316                                    let bind_group = device_for_apply.create_bind_group(
2317                                        &wgpu::BindGroupDescriptor {
2318                                            label: Some("pt_bind_group"),
2319                                            layout: bgl,
2320                                            entries: &[
2321                                                wgpu::BindGroupEntry {
2322                                                    binding: 0,
2323                                                    resource: uniform_buffer.as_entire_binding(),
2324                                                },
2325                                                wgpu::BindGroupEntry {
2326                                                    binding: 1,
2327                                                    resource: tet_buffer.as_entire_binding(),
2328                                                },
2329                                            ],
2330                                        },
2331                                    );
2332                                    crate::resources::types::ProjectedTetChunk {
2333                                        tet_buffer,
2334                                        tet_count,
2335                                        bind_group,
2336                                    }
2337                                })
2338                                .collect::<Vec<_>>()
2339                        };
2340                        let pid = ProjectedTetId(resources.content.projected_tet_store.push(
2341                            GpuProjectedTetMesh {
2342                                chunks,
2343                                uniform_buffer,
2344                                scalar_range,
2345                            },
2346                        ));
2347                        slot_for_apply.set((pid, scalar_range.0, scalar_range.1));
2348                    }),
2349                ))
2350            })
2351        };
2352
2353        self.job_results
2354            .projected_tet
2355            .lock()
2356            .expect("projected tet result map poisoned")
2357            .insert(id, slot);
2358        id
2359    }
2360
2361    /// Take the `(ProjectedTetId, scalar_min, scalar_max)` triple produced by a
2362    /// completed [`begin_upload_projected_tet`](Self::begin_upload_projected_tet) job.
2363    #[allow(dead_code)]
2364    pub(crate) fn upload_result_projected_tet(
2365        &mut self,
2366        id: crate::resources::JobId,
2367    ) -> crate::error::ViewportResult<(ProjectedTetId, f32, f32)> {
2368        let mut map = self
2369            .job_results
2370            .projected_tet
2371            .lock()
2372            .expect("projected tet result map poisoned");
2373        let slot = match map.get(&id) {
2374            Some(s) => s.clone(),
2375            None => {
2376                return Err(crate::error::ViewportError::JobResultMissing {
2377                    reason: "unknown id or wrong upload type",
2378                });
2379            }
2380        };
2381        match slot.take() {
2382            Some(triple) => {
2383                map.remove(&id);
2384                Ok(triple)
2385            }
2386            None => Err(crate::error::ViewportError::JobNotReady),
2387        }
2388    }
2389
2390    /// Replace the tet buffer of an existing projected-tet mesh in-place.
2391    ///
2392    /// Rebuilds the tet storage buffer (and its bind group) from the new scalar
2393    /// attribute. The uniform buffer (density, thresholds, opacity) is reused;
2394    /// only the cached scalar range is refreshed. Changing the colourmap on the
2395    /// owning item is now free because the LUT is bound per-frame in render.rs,
2396    /// so this call no longer takes a `colourmap_id`.
2397    pub fn replace_projected_tet(
2398        &mut self,
2399        device: &wgpu::Device,
2400        id: crate::resources::ProjectedTetId,
2401        data: &crate::resources::volume::volume_mesh::VolumeMeshData,
2402        scalar_attribute: &str,
2403    ) -> crate::error::ViewportResult<()> {
2404        self.ensure_pt_bind_group_layout(device);
2405
2406        let (pending, scalar_range, _new_uniform) =
2407            Self::decompose_into_chunks(device, data, scalar_attribute);
2408
2409        let chunks = {
2410            let bgl = self
2411                .pt
2412                .bind_group_layout
2413                .as_ref()
2414                .expect("pt_bind_group_layout must exist after ensure_pt_bind_group_layout");
2415            let uniform_buf = &self
2416                .content
2417                .projected_tet_store
2418                .get(id.0)
2419                .expect("ProjectedTetId must reference an uploaded mesh")
2420                .uniform_buffer;
2421            pending
2422                .into_iter()
2423                .map(|(tet_buffer, tet_count)| {
2424                    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
2425                        label: Some("pt_bind_group"),
2426                        layout: bgl,
2427                        entries: &[
2428                            wgpu::BindGroupEntry {
2429                                binding: 0,
2430                                resource: uniform_buf.as_entire_binding(),
2431                            },
2432                            wgpu::BindGroupEntry {
2433                                binding: 1,
2434                                resource: tet_buffer.as_entire_binding(),
2435                            },
2436                        ],
2437                    });
2438                    crate::resources::types::ProjectedTetChunk {
2439                        tet_buffer,
2440                        tet_count,
2441                        bind_group,
2442                    }
2443                })
2444                .collect::<Vec<_>>()
2445        };
2446
2447        let slot = self
2448            .content
2449            .projected_tet_store
2450            .get_mut(id.0)
2451            .expect("ProjectedTetId must reference an uploaded mesh");
2452        slot.chunks = chunks;
2453        slot.scalar_range = scalar_range;
2454
2455        Ok(())
2456    }
2457
2458    /// Decompose `data` into device-limit-bounded tet buffers and a shared uniform buffer.
2459    ///
2460    /// Returns `(pending_chunks, scalar_range, uniform_buffer)` where each element of
2461    /// `pending_chunks` is a `(wgpu::Buffer, tet_count)` pair ready for bind group creation.
2462    /// Bind groups are created separately so callers can supply the correct uniform buffer
2463    /// reference (new for upload, existing for replace).
2464    fn decompose_into_chunks(
2465        device: &wgpu::Device,
2466        data: &crate::resources::volume::volume_mesh::VolumeMeshData,
2467        scalar_attribute: &str,
2468    ) -> (Vec<(wgpu::Buffer, u32)>, (f32, f32), wgpu::Buffer) {
2469        // Determine the maximum tets per chunk from device limits.
2470        // Each tet is 64 bytes (4 x vec4<f32>).
2471        let max_binding = device.limits().max_storage_buffer_binding_size as u64;
2472        let max_buf = device.limits().max_buffer_size;
2473        let chunk_size_tets = ((max_binding.min(max_buf)) / 64).max(1) as usize;
2474
2475        let mut pending: Vec<(wgpu::Buffer, u32)> = Vec::new();
2476        let mut current_raw: Vec<f32> = Vec::with_capacity(chunk_size_tets * 16);
2477        let mut scalar_min = f32::INFINITY;
2478        let mut scalar_max = f32::NEG_INFINITY;
2479
2480        let flush = |raw: &mut Vec<f32>, pending: &mut Vec<(wgpu::Buffer, u32)>| {
2481            let tet_count = (raw.len() / 16) as u32;
2482            let buf = device.create_buffer(&wgpu::BufferDescriptor {
2483                label: Some("pt_tet_buffer"),
2484                size: (raw.len() * std::mem::size_of::<f32>()) as u64,
2485                usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
2486                mapped_at_creation: true,
2487            });
2488            buf.slice(..)
2489                .get_mapped_range_mut()
2490                .copy_from_slice(bytemuck::cast_slice(raw));
2491            buf.unmap();
2492            pending.push((buf, tet_count));
2493            raw.clear();
2494        };
2495
2496        crate::resources::volume::volume_mesh::for_each_tet(
2497            data,
2498            scalar_attribute,
2499            |verts, scalar| {
2500                scalar_min = scalar_min.min(scalar);
2501                scalar_max = scalar_max.max(scalar);
2502                current_raw.extend_from_slice(&[verts[0][0], verts[0][1], verts[0][2], scalar]);
2503                current_raw.extend_from_slice(&[verts[1][0], verts[1][1], verts[1][2], 0.0]);
2504                current_raw.extend_from_slice(&[verts[2][0], verts[2][1], verts[2][2], 0.0]);
2505                current_raw.extend_from_slice(&[verts[3][0], verts[3][1], verts[3][2], 0.0]);
2506                if current_raw.len() == chunk_size_tets * 16 {
2507                    flush(&mut current_raw, &mut pending);
2508                }
2509            },
2510        );
2511
2512        if !current_raw.is_empty() {
2513            flush(&mut current_raw, &mut pending);
2514        }
2515
2516        let scalar_range = if scalar_min.is_infinite() {
2517            (0.0f32, 1.0f32)
2518        } else {
2519            let max_s = if (scalar_max - scalar_min).abs() < 1e-12 {
2520                scalar_min + 1.0
2521            } else {
2522                scalar_max
2523            };
2524            (scalar_min, max_s)
2525        };
2526
2527        let initial_uniform = crate::resources::types::ProjectedTetUniform {
2528            density: 1.0,
2529            scalar_min: scalar_range.0,
2530            scalar_max: scalar_range.1,
2531            threshold_min: f32::NEG_INFINITY,
2532            threshold_max: f32::INFINITY,
2533            unlit: 0,
2534            opacity: 1.0,
2535            _pad: 0.0,
2536        };
2537        let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
2538            label: Some("pt_uniform_buf"),
2539            size: std::mem::size_of::<crate::resources::types::ProjectedTetUniform>() as u64,
2540            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2541            mapped_at_creation: true,
2542        });
2543        uniform_buffer
2544            .slice(..)
2545            .get_mapped_range_mut()
2546            .copy_from_slice(bytemuck::bytes_of(&initial_uniform));
2547        uniform_buffer.unmap();
2548
2549        (pending, scalar_range, uniform_buffer)
2550    }
2551}
2552
2553#[cfg(test)]
2554mod override_tests {
2555    use crate::DeviceResources;
2556    use crate::geometry::primitives;
2557
2558    fn try_make_device() -> Option<(wgpu::Device, wgpu::Queue)> {
2559        let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
2560        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
2561            power_preference: wgpu::PowerPreference::LowPower,
2562            compatible_surface: None,
2563            force_fallback_adapter: false,
2564        }))
2565        .ok()?;
2566        pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()
2567    }
2568
2569    fn dummy_override_buffer(device: &wgpu::Device, vertex_count: usize) -> wgpu::Buffer {
2570        device.create_buffer(&wgpu::BufferDescriptor {
2571            label: Some("test_override_buf"),
2572            size: (vertex_count * 12) as u64,
2573            usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
2574            mapped_at_creation: false,
2575        })
2576    }
2577
2578    #[test]
2579    fn set_position_override_roundtrip() {
2580        let Some((device, _queue)) = try_make_device() else {
2581            eprintln!("skipping: no wgpu adapter available");
2582            return;
2583        };
2584        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
2585        let plane = primitives::grid_plane(1.0, 1.0, 2, 2);
2586        let mesh_id = resources.upload_mesh_data(&device, &plane).unwrap();
2587        let vertex_count = plane.positions.len();
2588
2589        // Initial state.
2590        {
2591            let mesh = resources.mesh_store.get(mesh_id).unwrap();
2592            assert!(mesh.position_override_buffer.is_none());
2593            let gen0 = mesh.position_override_gen;
2594            assert_eq!(gen0, 0);
2595        }
2596
2597        let buf = dummy_override_buffer(&device, vertex_count);
2598        resources
2599            .set_position_override_buffer(mesh_id, buf)
2600            .unwrap();
2601
2602        {
2603            let mesh = resources.mesh_store.get(mesh_id).unwrap();
2604            assert!(mesh.position_override_buffer.is_some());
2605            assert_eq!(mesh.position_override_gen, 1);
2606        }
2607
2608        resources.clear_position_override(mesh_id).unwrap();
2609        {
2610            let mesh = resources.mesh_store.get(mesh_id).unwrap();
2611            assert!(mesh.position_override_buffer.is_none());
2612            assert_eq!(mesh.position_override_gen, 2);
2613        }
2614    }
2615
2616    #[test]
2617    fn set_normal_override_roundtrip() {
2618        let Some((device, _queue)) = try_make_device() else {
2619            eprintln!("skipping: no wgpu adapter available");
2620            return;
2621        };
2622        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
2623        let plane = primitives::grid_plane(1.0, 1.0, 2, 2);
2624        let mesh_id = resources.upload_mesh_data(&device, &plane).unwrap();
2625        let vertex_count = plane.positions.len();
2626
2627        let buf = dummy_override_buffer(&device, vertex_count);
2628        resources.set_normal_override_buffer(mesh_id, buf).unwrap();
2629        {
2630            let mesh = resources.mesh_store.get(mesh_id).unwrap();
2631            assert!(mesh.normal_override_buffer.is_some());
2632            assert_eq!(mesh.normal_override_gen, 1);
2633        }
2634
2635        resources.clear_normal_override(mesh_id).unwrap();
2636        {
2637            let mesh = resources.mesh_store.get(mesh_id).unwrap();
2638            assert!(mesh.normal_override_buffer.is_none());
2639            assert_eq!(mesh.normal_override_gen, 2);
2640        }
2641    }
2642
2643    #[test]
2644    fn override_on_unknown_mesh_id_errors() {
2645        let Some((device, _queue)) = try_make_device() else {
2646            eprintln!("skipping: no wgpu adapter available");
2647            return;
2648        };
2649        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
2650        // Fabricate an id that is beyond the store.
2651        let bogus = crate::resources::mesh::mesh_store::MeshId::new(9999, 0);
2652        let buf = dummy_override_buffer(&device, 4);
2653        let err = resources.set_position_override_buffer(bogus, buf);
2654        assert!(matches!(
2655            err,
2656            Err(crate::error::ViewportError::StaleHandle { .. })
2657        ));
2658    }
2659
2660    #[test]
2661    fn stale_mesh_handle_does_not_alias_after_slot_reuse() {
2662        let Some((device, _queue)) = try_make_device() else {
2663            eprintln!("skipping: no wgpu adapter available");
2664            return;
2665        };
2666        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
2667
2668        // Upload a mesh, then remove it. The handle is now stale.
2669        let id1 = resources
2670            .upload_mesh_data(&device, &primitives::cube(1.0))
2671            .unwrap();
2672        assert!(resources.mesh_store.get(id1).is_some());
2673        assert!(resources.free_mesh(id1));
2674        assert!(
2675            resources.mesh_store.get(id1).is_none(),
2676            "a removed handle must not resolve"
2677        );
2678
2679        // The next upload reuses the freed slot but at a new generation.
2680        let id2 = resources
2681            .upload_mesh_data(&device, &primitives::cube(2.0))
2682            .unwrap();
2683        assert_eq!(id1.index(), id2.index(), "the freed slot should be reused");
2684        assert_ne!(
2685            id1, id2,
2686            "the reused slot must carry a new generation so the old handle differs"
2687        );
2688        assert!(resources.mesh_store.get(id2).is_some());
2689        assert!(
2690            resources.mesh_store.get(id1).is_none(),
2691            "the stale handle must not alias the mesh now occupying its slot"
2692        );
2693    }
2694
2695    #[test]
2696    fn stale_handle_replace_is_rejected_after_free_and_reuse() {
2697        // The in-flight guard: an operation carrying a handle whose mesh was
2698        // freed (and whose slot a later upload reused) must not land on the new
2699        // mesh. `replace_mesh_data` is the slot-targeting path; its generation
2700        // check is what makes a free racing a queued replace safe.
2701        let Some((device, queue)) = try_make_device() else {
2702            eprintln!("skipping: no wgpu adapter available");
2703            return;
2704        };
2705        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
2706
2707        let stale = resources
2708            .upload_mesh_data(&device, &primitives::cube(1.0))
2709            .unwrap();
2710        assert!(resources.free_mesh(stale));
2711
2712        // A later upload reuses the freed slot at a new generation.
2713        let live = resources
2714            .upload_mesh_data(&device, &primitives::cube(2.0))
2715            .unwrap();
2716        assert_eq!(stale.index(), live.index());
2717
2718        // Replaying the stale handle must be rejected, not silently overwrite
2719        // the mesh now occupying the slot.
2720        let err = resources.replace_mesh_data(&device, &queue, stale, &primitives::cube(3.0));
2721        assert!(
2722            matches!(err, Err(crate::error::ViewportError::StaleHandle { .. })),
2723            "replace through a stale handle must fail rather than alias the reused slot"
2724        );
2725        assert!(
2726            resources.mesh_store.get(live).is_some(),
2727            "the live mesh must be untouched by the rejected replace"
2728        );
2729    }
2730
2731    #[test]
2732    fn resident_bytes_track_mesh_upload_and_free() {
2733        let Some((device, _queue)) = try_make_device() else {
2734            eprintln!("skipping: no wgpu adapter available");
2735            return;
2736        };
2737        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
2738
2739        let start = resources.resident_bytes().mesh_bytes;
2740        let id = resources
2741            .upload_mesh_data(&device, &primitives::cube(1.0))
2742            .unwrap();
2743        let after_upload = resources.resident_bytes().mesh_bytes;
2744        assert!(
2745            after_upload > start,
2746            "uploading a mesh must increase resident mesh bytes"
2747        );
2748
2749        assert!(resources.free_mesh(id));
2750        let after_free = resources.resident_bytes().mesh_bytes;
2751        assert_eq!(
2752            after_free, start,
2753            "freeing the mesh must return resident bytes to the starting total"
2754        );
2755    }
2756}
2757
2758#[cfg(test)]
2759mod async_upload_tests {
2760    use crate::DeviceResources;
2761    use crate::geometry::primitives;
2762    use crate::resources::UploadStatus;
2763
2764    fn try_make_device() -> Option<(wgpu::Device, wgpu::Queue)> {
2765        let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
2766        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
2767            power_preference: wgpu::PowerPreference::LowPower,
2768            compatible_surface: None,
2769            force_fallback_adapter: false,
2770        }))
2771        .ok()?;
2772        pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()
2773    }
2774
2775    fn drive_until_ready(
2776        resources: &mut DeviceResources,
2777        device: &wgpu::Device,
2778        queue: &wgpu::Queue,
2779        id: crate::resources::JobId,
2780    ) {
2781        for _ in 0..200 {
2782            resources.process_uploads(device, queue);
2783            match resources.upload_status(id) {
2784                UploadStatus::Ready => return,
2785                UploadStatus::Failed(e) => panic!("upload failed: {e:?}"),
2786                UploadStatus::Pending { .. } => {
2787                    std::thread::sleep(std::time::Duration::from_millis(5));
2788                }
2789                UploadStatus::Unknown => panic!("job id disappeared"),
2790            }
2791        }
2792        panic!("mesh upload did not complete in time");
2793    }
2794
2795    #[test]
2796    fn invalid_mesh_data_errors_synchronously() {
2797        let Some((device, _queue)) = try_make_device() else {
2798            eprintln!("skipping: no wgpu adapter available");
2799            return;
2800        };
2801        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
2802
2803        let empty = crate::resources::MeshData::default();
2804        let err = resources
2805            .begin_upload_mesh_data(&device, empty)
2806            .expect_err("empty mesh should be rejected");
2807        assert!(matches!(err, crate::error::ViewportError::EmptyMesh { .. }));
2808        assert_eq!(resources.uploads_pending(), 0);
2809    }
2810
2811    #[test]
2812    fn begin_upload_completes_and_yields_mesh_id() {
2813        let Some((device, queue)) = try_make_device() else {
2814            eprintln!("skipping: no wgpu adapter available");
2815            return;
2816        };
2817        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
2818
2819        let plane = primitives::grid_plane(1.0, 1.0, 8, 8);
2820        let id = resources
2821            .begin_upload_mesh_data(&device, plane.clone())
2822            .unwrap();
2823        assert_eq!(resources.uploads_pending(), 1);
2824
2825        // Result should not be available until the worker finishes.
2826        let err = resources.upload_result_mesh(id).unwrap_err();
2827        assert!(matches!(err, crate::error::ViewportError::JobNotReady));
2828
2829        drive_until_ready(&mut resources, &device, &queue, id);
2830
2831        let mesh_id = resources.upload_result_mesh(id).expect("ready result");
2832        assert!(resources.mesh_store.get(mesh_id).is_some());
2833
2834        // Second take of the same id should now report missing.
2835        let err = resources.upload_result_mesh(id).unwrap_err();
2836        assert!(matches!(
2837            err,
2838            crate::error::ViewportError::JobResultMissing { .. }
2839        ));
2840    }
2841
2842    #[test]
2843    fn sync_upload_still_works_alongside_async() {
2844        let Some((device, _queue)) = try_make_device() else {
2845            eprintln!("skipping: no wgpu adapter available");
2846            return;
2847        };
2848        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
2849
2850        let plane = primitives::grid_plane(1.0, 1.0, 4, 4);
2851        let mesh_id = resources.upload_mesh_data(&device, &plane).unwrap();
2852        assert!(resources.mesh_store.get(mesh_id).is_some());
2853    }
2854
2855    #[test]
2856    fn unknown_job_id_returns_missing() {
2857        let Some((device, _queue)) = try_make_device() else {
2858            eprintln!("skipping: no wgpu adapter available");
2859            return;
2860        };
2861        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
2862
2863        // Submit an env-map job so we have a live JobId of the wrong type.
2864        let pixels = vec![0.5f32; 8 * 4 * 4];
2865        let other_id = crate::resources::material::environment::begin_upload_environment_map(
2866            &mut resources,
2867            &device,
2868            &try_make_device().unwrap().1,
2869            pixels,
2870            8,
2871            4,
2872        )
2873        .unwrap();
2874
2875        let err = resources.upload_result_mesh(other_id).unwrap_err();
2876        assert!(matches!(
2877            err,
2878            crate::error::ViewportError::JobResultMissing { .. }
2879        ));
2880    }
2881}
2882
2883#[cfg(test)]
2884mod c4_volume_mesh_tests {
2885    use crate::DeviceResources;
2886    use crate::resources::volume::volume_mesh::VolumeMeshData;
2887    use crate::resources::{CELL_SENTINEL, SparseVolumeGridData, UploadStatus};
2888
2889    fn try_make_device() -> Option<(wgpu::Device, wgpu::Queue)> {
2890        let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
2891        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
2892            power_preference: wgpu::PowerPreference::LowPower,
2893            compatible_surface: None,
2894            force_fallback_adapter: false,
2895        }))
2896        .ok()?;
2897        pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()
2898    }
2899
2900    fn drive_until_ready(
2901        resources: &mut DeviceResources,
2902        device: &wgpu::Device,
2903        queue: &wgpu::Queue,
2904        id: crate::resources::JobId,
2905        label: &str,
2906    ) {
2907        for _ in 0..200 {
2908            resources.process_uploads(device, queue);
2909            match resources.upload_status(id) {
2910                UploadStatus::Ready => return,
2911                UploadStatus::Failed(e) => panic!("{label} upload failed: {e:?}"),
2912                UploadStatus::Pending { .. } => {
2913                    std::thread::sleep(std::time::Duration::from_millis(5));
2914                }
2915                UploadStatus::Unknown => panic!("{label} job id disappeared"),
2916            }
2917        }
2918        panic!("{label} upload did not complete in time");
2919    }
2920
2921    fn single_tet_volume() -> VolumeMeshData {
2922        let mut v = VolumeMeshData::default();
2923        v.positions = vec![
2924            [0.0, 0.0, 0.0],
2925            [1.0, 0.0, 0.0],
2926            [0.5, 1.0, 0.0],
2927            [0.5, 0.5, 1.0],
2928        ];
2929        v.cells = vec![[
2930            0,
2931            1,
2932            2,
2933            3,
2934            CELL_SENTINEL,
2935            CELL_SENTINEL,
2936            CELL_SENTINEL,
2937            CELL_SENTINEL,
2938        ]];
2939        v.cell_scalars.insert("density".into(), vec![0.5]);
2940        v
2941    }
2942
2943    fn single_cell_sparse() -> SparseVolumeGridData {
2944        let mut g = SparseVolumeGridData::default();
2945        g.active_cells = vec![[0, 0, 0]];
2946        g.cell_size = 1.0;
2947        g.origin = [0.0, 0.0, 0.0];
2948        g
2949    }
2950
2951    #[test]
2952    fn begin_upload_volume_mesh_drains_to_pair() {
2953        let Some((device, queue)) = try_make_device() else {
2954            eprintln!("skipping: no wgpu adapter available");
2955            return;
2956        };
2957        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
2958        let job = resources.begin_upload_volume_mesh(&device, single_tet_volume());
2959        drive_until_ready(&mut resources, &device, &queue, job, "volume_mesh");
2960        let item = resources.upload_result_volume_mesh(job).expect("ready");
2961        assert!(resources.mesh_store.get(item.boundary_mesh_id).is_some());
2962        assert!(!item.face_to_cell.is_empty());
2963        let res = resources.upload_result_volume_mesh(job);
2964        assert!(matches!(
2965            res,
2966            Err(crate::error::ViewportError::JobResultMissing { .. })
2967        ));
2968    }
2969
2970    #[test]
2971    fn begin_upload_clipped_volume_mesh_drains_to_pair() {
2972        let Some((device, queue)) = try_make_device() else {
2973            eprintln!("skipping: no wgpu adapter available");
2974            return;
2975        };
2976        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
2977        // Empty clip planes: equivalent to plain volume mesh extraction.
2978        let job =
2979            resources.begin_upload_clipped_volume_mesh(&device, single_tet_volume(), Vec::new());
2980        drive_until_ready(&mut resources, &device, &queue, job, "clipped_volume_mesh");
2981        let item = resources
2982            .upload_result_clipped_volume_mesh(job)
2983            .expect("ready");
2984        assert!(resources.mesh_store.get(item.boundary_mesh_id).is_some());
2985        assert!(!item.face_to_cell.is_empty());
2986    }
2987
2988    #[test]
2989    fn begin_upload_sparse_volume_grid_drains_to_handle() {
2990        let Some((device, queue)) = try_make_device() else {
2991            eprintln!("skipping: no wgpu adapter available");
2992            return;
2993        };
2994        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
2995        let job = resources.begin_upload_sparse_volume_grid_data(&device, single_cell_sparse());
2996        drive_until_ready(&mut resources, &device, &queue, job, "sparse_volume_grid");
2997        let mesh_id = resources
2998            .upload_result_sparse_volume_grid(job)
2999            .expect("ready");
3000        assert!(resources.mesh_store.get(mesh_id).is_some());
3001    }
3002
3003    #[test]
3004    fn begin_upload_projected_tet_drains_to_triple() {
3005        let Some((device, queue)) = try_make_device() else {
3006            eprintln!("skipping: no wgpu adapter available");
3007            return;
3008        };
3009        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
3010        let job =
3011            resources.begin_upload_projected_tet(&device, single_tet_volume(), "density".into());
3012        drive_until_ready(&mut resources, &device, &queue, job, "projected_tet");
3013        let (_id, smin, smax) = resources.upload_result_projected_tet(job).expect("ready");
3014        assert!(smin <= smax);
3015    }
3016
3017    #[test]
3018    fn sync_paths_still_work() {
3019        let Some((device, _queue)) = try_make_device() else {
3020            eprintln!("skipping: no wgpu adapter available");
3021            return;
3022        };
3023        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
3024        let vol = single_tet_volume();
3025        let _item = resources
3026            .upload_volume_mesh(&device, &vol)
3027            .expect("sync ok");
3028        let _item2 = resources
3029            .upload_clipped_volume_mesh(&device, &vol, &[])
3030            .expect("clipped sync ok");
3031        let _grid_id = resources
3032            .upload_sparse_volume_grid_data(&device, &single_cell_sparse())
3033            .expect("sparse sync ok");
3034    }
3035}
3036
3037/// Raw mesh data for upload to the GPU. Framework-agnostic representation.
3038#[derive(Clone)]
3039#[non_exhaustive]
3040pub struct MeshData {
3041    /// Vertex positions in local space.
3042    pub positions: Vec<[f32; 3]>,
3043    /// Per-vertex normals (must be the same length as `positions`).
3044    pub normals: Vec<[f32; 3]>,
3045    /// Triangle index list (every 3 indices form one triangle).
3046    pub indices: Vec<u32>,
3047    /// Optional per-vertex UV coordinates. `None` means zero-fill [0.0, 0.0].
3048    pub uvs: Option<Vec<[f32; 2]>>,
3049    /// Optional per-vertex tangents [tx, ty, tz, w] where w is handedness (+/-1.0).
3050    ///
3051    /// `None` = auto-compute from UVs if available, or zero-fill otherwise.
3052    /// Tangents are required for correct normal map rendering.
3053    pub tangents: Option<Vec<[f32; 4]>>,
3054    /// Named scalar attributes for per-vertex or per-cell scalar field visualisation.
3055    ///
3056    /// Keys are user-defined attribute names (e.g. `"pressure"`, `"velocity_mag"`).
3057    /// Cell attributes are averaged to vertices at upload time.
3058    pub attributes: std::collections::HashMap<String, AttributeData>,
3059}
3060
3061impl Default for MeshData {
3062    fn default() -> Self {
3063        Self {
3064            positions: Vec::new(),
3065            normals: Vec::new(),
3066            indices: Vec::new(),
3067            uvs: None,
3068            tangents: None,
3069            attributes: std::collections::HashMap::new(),
3070        }
3071    }
3072}
3073
3074impl MeshData {
3075    /// Compute the local-space AABB from vertex positions.
3076    pub fn compute_aabb(&self) -> crate::scene::aabb::Aabb {
3077        crate::scene::aabb::Aabb::from_positions(&self.positions)
3078    }
3079}
3080
3081impl crate::resources::DeviceResources {
3082    /// Write new scalar data into an existing attribute buffer in-place.
3083    ///
3084    /// No GPU buffer reallocation, no mesh re-upload, no bind group rebuild is
3085    /// required. The attribute bind group *will* be rebuilt on the next
3086    /// `prepare()` call if the scalar range changes (tracked via `last_tex_key`).
3087    ///
3088    /// # Errors
3089    ///
3090    /// - [`ViewportError::SlotEmpty`](crate::error::ViewportError::SlotEmpty) : `mesh_id` not found in the store.
3091    /// - [`ViewportError::AttributeNotFound`](crate::error::ViewportError::AttributeNotFound) : `name` not present on the mesh.
3092    /// - [`ViewportError::AttributeLengthMismatch`](crate::error::ViewportError::AttributeLengthMismatch) : `data.len()` differs from
3093    ///   the original upload (same-topology requirement).
3094    pub fn replace_attribute(
3095        &mut self,
3096        queue: &wgpu::Queue,
3097        mesh_id: crate::resources::mesh::mesh_store::MeshId,
3098        name: &str,
3099        data: &[f32],
3100    ) -> crate::error::ViewportResult<()> {
3101        // Resolve the mesh.
3102        let gpu_mesh =
3103            self.mesh_store
3104                .get_mut(mesh_id)
3105                .ok_or(crate::error::ViewportError::SlotEmpty {
3106                    index: mesh_id.index(),
3107                })?;
3108
3109        // Find the existing attribute buffer.
3110        let buffer = gpu_mesh.attribute_buffers.get(name).ok_or_else(|| {
3111            crate::error::ViewportError::AttributeNotFound {
3112                mesh_id: mesh_id.index(),
3113                name: name.to_string(),
3114            }
3115        })?;
3116
3117        // Validate same topology (buffer size must match).
3118        let expected_elems = (buffer.size() / 4) as usize;
3119        if data.len() != expected_elems {
3120            return Err(crate::error::ViewportError::AttributeLengthMismatch {
3121                expected: expected_elems,
3122                got: data.len(),
3123            });
3124        }
3125
3126        // Zero-copy in-place write via the wgpu staging belt.
3127        queue.write_buffer(buffer, 0, bytemuck::cast_slice(data));
3128
3129        // Recompute scalar range so LUT mapping stays accurate.
3130        let (min, max) = data
3131            .iter()
3132            .fold((f32::MAX, f32::MIN), |(mn, mx), &v| (mn.min(v), mx.max(v)));
3133        let range = if min > max { (0.0, 1.0) } else { (min, max) };
3134        gpu_mesh.attribute_ranges.insert(name.to_string(), range);
3135
3136        // Force bind group rebuild on next prepare() by invalidating the key.
3137        gpu_mesh.last_tex_key = (
3138            gpu_mesh.last_tex_key.0,
3139            gpu_mesh.last_tex_key.1,
3140            gpu_mesh.last_tex_key.2,
3141            gpu_mesh.last_tex_key.3,
3142            u64::MAX, // attribute hash component
3143            gpu_mesh.last_tex_key.5,
3144            gpu_mesh.last_tex_key.6,
3145            gpu_mesh.last_tex_key.7,
3146            gpu_mesh.last_tex_key.8,
3147            gpu_mesh.last_tex_key.9,
3148            gpu_mesh.last_tex_key.10,
3149        );
3150
3151        Ok(())
3152    }
3153}
3154
3155/// Linearly interpolate between two attribute buffers element-wise.
3156///
3157/// Both slices must have the same length. `t` is clamped to `[0.0, 1.0]`.
3158/// Returns a new `Vec<f32>` with `a[i] * (1 - t) + b[i] * t`.
3159///
3160/// Use this to blend per-vertex scalar attributes between two consecutive
3161/// timesteps when scrubbing the timeline at sub-frame resolution.
3162pub fn lerp_attributes(a: &[f32], b: &[f32], t: f32) -> Vec<f32> {
3163    let t = t.clamp(0.0, 1.0);
3164    let one_minus_t = 1.0 - t;
3165    a.iter()
3166        .zip(b.iter())
3167        .map(|(&av, &bv)| av * one_minus_t + bv * t)
3168        .collect()
3169}