Skip to main content

teamcraft/
preview.rs

1//! 3D preview of CAD files – pure Rust, no browser plug-ins or downloads.
2//!
3//! The server converts a file once into the compact mesh of [`crate::mesh`]
4//! and caches it next to the vault (`previews/ab/<sha256>.v<N>.tcm`); the
5//! browser (`shared/js/viewer.js`) and the desktop app only draw it. Meshes,
6//! point clouds, STEP B-reps (tessellated with the `truck` kernel) and
7//! simulation results (VTK, VTU/VTP, CalculiX FRD with result fields, see
8//! [`crate::simmesh`]) are supported, see [`PreviewFormat`].
9//!
10//! The conversion never blocks the web server: it runs in a separate
11//! `teamcraft preview-mesh` process with a time limit (in tests, where there is
12//! no such binary, on a blocking thread), at most [`MAX_PARALLEL`] at a time,
13//! and concurrent requests for the same file share one conversion. Failures
14//! are cached as well, so a broken file is not converted again and again.
15
16use std::{
17    collections::HashMap,
18    path::{Path, PathBuf},
19    sync::{
20        atomic::{AtomicUsize, Ordering},
21        Arc, Mutex,
22    },
23    time::Duration,
24};
25
26use tokio::sync::{OwnedSemaphorePermit, Semaphore, SemaphorePermit};
27
28use crate::{
29    error::{AppError, AppResult},
30    massprops, shape,
31    vault::Vault,
32};
33
34/// Bump to invalidate every cached preview (reader fixes, new format).
35/// Bump `shape::VERSION` with it: the shape signatures stored in the
36/// database are computed from these meshes. 3: Inventor files with
37/// Zstandard segments (#271), cached as failed before. 4: Creo parts in
38/// millimetres (inch parts were 25.4 times too small), turned Creo parts.
39pub const CACHE_VERSION: u32 = 4;
40
41/// Conversions that run at the same time.
42pub const MAX_PARALLEL: usize = 2;
43
44/// Thumbnails drawn and assembly previews composed at the same time
45/// ([`Previews::render`]): each keeps one CPU busy and holds its meshes in
46/// memory.
47pub const MAX_RENDERS: usize = 2;
48
49/// Size and memory limits of the preview (`TEAMCRAFT_PREVIEW_MAX_*`).
50///
51/// A file above the cap of its kind is never opened: no conversion is
52/// started, the refusal is cached. Result files (VTK, VTU, VTP, FRD) are the
53/// ones that get huge and are not meant to be parsed fully – a preview of
54/// them is a courtesy, not a feature the server may die for.
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub struct PreviewLimits {
57    /// Simulation result files.
58    pub max_result_bytes: u64,
59    /// Meshes and point clouds.
60    pub max_mesh_bytes: u64,
61    /// STEP B-reps (tessellation needs a multiple of the file size).
62    pub max_step_bytes: u64,
63    /// Address space of the preview process (0 = unlimited; Linux/macOS).
64    pub max_memory_bytes: u64,
65}
66
67const MIB: u64 = 1024 * 1024;
68
69impl Default for PreviewLimits {
70    fn default() -> Self {
71        PreviewLimits {
72            max_result_bytes: 64 * MIB,
73            max_mesh_bytes: 256 * MIB,
74            max_step_bytes: crate::mesh::MAX_STEP_BYTES as u64,
75            max_memory_bytes: 1024 * MIB,
76        }
77    }
78}
79
80impl PreviewLimits {
81    /// The largest input of a format.
82    pub fn max_bytes(&self, format: PreviewFormat) -> u64 {
83        if format.is_result() {
84            self.max_result_bytes
85        } else if format.is_brep() {
86            self.max_step_bytes
87        } else {
88            self.max_mesh_bytes
89        }
90    }
91}
92
93/// Start of every refusal because of the file size (also what is cached).
94const TOO_LARGE: &str = "This file is too large for a 3D preview";
95
96/// Why a file of `size` bytes gets no preview.
97fn too_large_message(size: u64, limit: u64) -> String {
98    format!(
99        "{TOO_LARGE} ({} MB, at most {} MB) – attach a rendered picture or a report, \
100         or download the file.",
101        size.div_ceil(MIB),
102        limit / MIB
103    )
104}
105
106/// Refuses a file above `limit` before it is opened.
107fn check_size(input: &Path, limit: u64) -> anyhow::Result<()> {
108    let size = std::fs::metadata(input)?.len();
109    if size > limit {
110        anyhow::bail!("{}", too_large_message(size, limit));
111    }
112    Ok(())
113}
114
115/// Limits the address space of this process (the preview child), so a file
116/// that needs more memory fails cleanly instead of taking the server host
117/// down. Linux and macOS; elsewhere (Windows) the limit is not enforced.
118/// Returns whether a limit is in force.
119pub fn limit_memory(bytes: u64) -> bool {
120    if bytes == 0 {
121        return false;
122    }
123    #[cfg(unix)]
124    {
125        rlimit::setrlimit(rlimit::Resource::AS, bytes, bytes).is_ok()
126    }
127    #[cfg(not(unix))]
128    {
129        false
130    }
131}
132
133/// The message for a preview process that stopped without an error message
134/// (`stderr` = what it printed last): memory exhausted or killed.
135fn describe_failure(aborted: bool, stderr: &str, memory_limit: u64) -> String {
136    let out_of_memory = aborted
137        || stderr.contains("memory allocation")
138        || stderr.contains("out of memory")
139        || stderr.contains("capacity overflow");
140    if out_of_memory && memory_limit > 0 {
141        return format!(
142            "The preview needs more memory than allowed ({} MB) – attach a rendered picture \
143             or a report, or download the file.",
144            memory_limit / MIB
145        );
146    }
147    stderr
148        .lines()
149        .rev()
150        .find(|l| !l.trim().is_empty())
151        .unwrap_or("the converter stopped unexpectedly")
152        .trim_start_matches("Error: ")
153        .chars()
154        .take(500)
155        .collect()
156}
157
158/// 3D formats the preview understands.
159#[derive(Clone, Copy, Debug, PartialEq, Eq)]
160pub enum PreviewFormat {
161    Stl,
162    Obj,
163    Ply,
164    Off,
165    ThreeMf,
166    Amf,
167    Gltf,
168    Glb,
169    ThreeDs,
170    Step,
171    /// Siemens NX parts: the lightweight (JT) representation they store.
172    NxPart,
173    /// Standalone JT visualisation files: JT 10 with its scene graph (parts
174    /// and assemblies), JT 9 as NX writes it.
175    Jt,
176    /// SOLIDWORKS parts: the display tessellation they store.
177    SwPart,
178    /// SOLIDWORKS assemblies: put together from the previews of their parts.
179    SwAssembly,
180    /// Rhino models: the meshes they store, SubD control nets refined.
181    Rhino,
182    /// Inventor parts: the display tessellation they store.
183    InvPart,
184    /// Inventor assemblies: put together from the previews of their parts.
185    InvAssembly,
186    /// CATIA V5 parts: the visualisation (CGR) data they store.
187    CatiaPart,
188    /// CATIA V5 products: put together from the previews of their parts.
189    CatiaProduct,
190    /// SketchUp models: their faces triangulated, components placed.
191    SketchUp,
192    /// PTC Creo parts: tessellated from the B-rep they store.
193    CreoPart,
194    /// PTC Creo assemblies: put together from the previews of their parts.
195    CreoAssembly,
196    /// Autodesk Fusion designs: the display scene they store.
197    FusionDesign,
198    Pts,
199    Xyz,
200    Las,
201    /// Simulation results (see `simmesh`): legacy VTK, VTU, VTP, CalculiX FRD.
202    VtkLegacy,
203    Vtu,
204    Vtp,
205    Frd,
206}
207
208impl PreviewFormat {
209    pub const ALL: [PreviewFormat; 30] = [
210        PreviewFormat::Stl,
211        PreviewFormat::Obj,
212        PreviewFormat::Ply,
213        PreviewFormat::Off,
214        PreviewFormat::ThreeMf,
215        PreviewFormat::Amf,
216        PreviewFormat::Gltf,
217        PreviewFormat::Glb,
218        PreviewFormat::ThreeDs,
219        PreviewFormat::Step,
220        PreviewFormat::NxPart,
221        PreviewFormat::Jt,
222        PreviewFormat::SwPart,
223        PreviewFormat::SwAssembly,
224        PreviewFormat::Rhino,
225        PreviewFormat::InvPart,
226        PreviewFormat::InvAssembly,
227        PreviewFormat::CatiaPart,
228        PreviewFormat::CatiaProduct,
229        PreviewFormat::SketchUp,
230        PreviewFormat::CreoPart,
231        PreviewFormat::CreoAssembly,
232        PreviewFormat::FusionDesign,
233        PreviewFormat::Pts,
234        PreviewFormat::Xyz,
235        PreviewFormat::Las,
236        PreviewFormat::VtkLegacy,
237        PreviewFormat::Vtu,
238        PreviewFormat::Vtp,
239        PreviewFormat::Frd,
240    ];
241
242    /// Detects the format from the file name (see `cadformat`).
243    pub fn of(filename: &str) -> Option<PreviewFormat> {
244        crate::cadformat::detect(filename.trim(), &[]).and_then(|f| Self::of_format(f.id))
245    }
246
247    /// The preview of a stored format id (`files.format`).
248    pub fn of_format(format_id: &str) -> Option<PreviewFormat> {
249        Self::ALL.into_iter().find(|p| p.as_str() == format_id)
250    }
251
252    /// The `cadformat` id of the format.
253    pub fn as_str(self) -> &'static str {
254        match self {
255            PreviewFormat::Stl => "stl",
256            PreviewFormat::Obj => "obj",
257            PreviewFormat::Ply => "ply",
258            PreviewFormat::Off => "off",
259            PreviewFormat::ThreeMf => "3mf",
260            PreviewFormat::Amf => "amf",
261            PreviewFormat::Gltf => "gltf",
262            PreviewFormat::Glb => "glb",
263            PreviewFormat::ThreeDs => "3ds",
264            PreviewFormat::Step => "step",
265            PreviewFormat::NxPart => "nx-part",
266            PreviewFormat::Jt => "jt",
267            PreviewFormat::SwPart => "solidworks-part",
268            PreviewFormat::SwAssembly => "solidworks-assembly",
269            PreviewFormat::Rhino => "rhino",
270            PreviewFormat::InvPart => "inventor-part",
271            PreviewFormat::InvAssembly => "inventor-assembly",
272            PreviewFormat::CatiaPart => "catia-part",
273            PreviewFormat::CatiaProduct => "catia-product",
274            PreviewFormat::SketchUp => "sketchup",
275            PreviewFormat::CreoPart => "creo-part",
276            PreviewFormat::CreoAssembly => "creo-assembly",
277            PreviewFormat::FusionDesign => "fusion-design",
278            PreviewFormat::Pts => "pts",
279            PreviewFormat::Xyz => "xyz",
280            PreviewFormat::Las => "las",
281            PreviewFormat::VtkLegacy => "vtk-legacy",
282            PreviewFormat::Vtu => "vtk-unstructured",
283            PreviewFormat::Vtp => "vtk-polydata",
284            PreviewFormat::Frd => "calculix-frd",
285        }
286    }
287
288    /// Simulation results: the outer surface coloured by result fields.
289    pub fn is_result(self) -> bool {
290        matches!(
291            self,
292            PreviewFormat::VtkLegacy | PreviewFormat::Vtu | PreviewFormat::Vtp | PreviewFormat::Frd
293        )
294    }
295
296    /// B-rep formats are tessellated (slower) – the rest is read as is.
297    pub fn is_brep(self) -> bool {
298        matches!(self, PreviewFormat::Step | PreviewFormat::CreoPart)
299    }
300
301    /// Assemblies whose preview is put together from their parts' previews
302    /// (`services::assemblies`): NX files without a lightweight
303    /// representation of their own, SOLIDWORKS and Inventor assemblies,
304    /// CATIA products, Creo assemblies.
305    pub fn may_be_assembly(self) -> bool {
306        matches!(
307            self,
308            PreviewFormat::NxPart
309                | PreviewFormat::SwAssembly
310                | PreviewFormat::InvAssembly
311                | PreviewFormat::CatiaProduct
312                | PreviewFormat::CreoAssembly
313        )
314    }
315
316    pub fn is_point_cloud(self) -> bool {
317        matches!(
318            self,
319            PreviewFormat::Pts | PreviewFormat::Xyz | PreviewFormat::Las
320        )
321    }
322
323    /// Time limit of one conversion.
324    pub fn time_limit(self) -> Duration {
325        if self.is_brep() {
326            Duration::from_secs(180)
327        } else if self.is_result() {
328            // large volume meshes: boundary extraction and decompression
329            Duration::from_secs(120)
330        } else {
331            Duration::from_secs(60)
332        }
333    }
334}
335
336/// Converts the file at `input` (tests and tools).
337pub fn convert_file(format: PreviewFormat, input: &Path) -> anyhow::Result<Vec<u8>> {
338    Ok(convert(format, input)?.encode())
339}
340
341/// Reads the file at `input` as a preview mesh.
342pub fn convert(format: PreviewFormat, input: &Path) -> anyhow::Result<crate::mesh::Mesh> {
343    convert_within(format, input, PreviewLimits::default().max_bytes(format))
344}
345
346/// Like [`convert`]; a file above `max_bytes` is refused before it is opened.
347pub fn convert_within(
348    format: PreviewFormat,
349    input: &Path,
350    max_bytes: u64,
351) -> anyhow::Result<crate::mesh::Mesh> {
352    check_size(input, max_bytes)?;
353    let data = std::fs::read(input)?;
354    crate::mesh::read(format.as_str(), &data)
355}
356
357/// `--format` of `teamcraft preview-mesh` for an already converted preview
358/// mesh (only its shape signature is computed).
359pub const CACHED_MESH: &str = "tcm";
360
361/// One job of the preview process (`teamcraft preview-mesh`): converts
362/// `input` into the preview mesh at `output` and/or writes the shape
363/// signature (a [`shape::Outcome`] as JSON) to `signature`. With
364/// [`CACHED_MESH`] as format, `input` is a cached preview mesh. A failed
365/// signature does not fail the conversion – the outcome records why. The
366/// mass properties (a [`massprops::Outcome`] as JSON) go to `mass` the same
367/// way. Returns the mesh (the CLI prints its size).
368pub fn run_job(
369    format: &str,
370    input: &Path,
371    output: Option<&Path>,
372    signature: Option<&Path>,
373    mass: Option<&Path>,
374    max_input_bytes: u64,
375) -> anyhow::Result<crate::mesh::Mesh> {
376    let mesh = if format == CACHED_MESH {
377        check_size(input, max_input_bytes)?;
378        let mesh = crate::mesh::Mesh::decode(&std::fs::read(input)?)?;
379        mesh.check()?;
380        mesh
381    } else {
382        let format = PreviewFormat::of_format(format)
383            .ok_or_else(|| anyhow::anyhow!("no 3D preview for the format '{format}'"))?;
384        convert_within(format, input, max_input_bytes)?
385    };
386    if let Some(output) = output {
387        std::fs::write(output, mesh.encode())?;
388    }
389    if let Some(path) = signature {
390        std::fs::write(path, serde_json::to_vec(&signature_outcome(&mesh))?)?;
391    }
392    if let Some(path) = mass {
393        std::fs::write(path, serde_json::to_vec(&mass_outcome(&mesh))?)?;
394    }
395    Ok(mesh)
396}
397
398/// The mass properties of a preview mesh (per unit density, model units),
399/// or why there are none.
400fn mass_outcome(mesh: &crate::mesh::Mesh) -> massprops::Outcome {
401    let computed =
402        std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| massprops::compute(mesh)));
403    match computed {
404        Ok(Ok(g)) => massprops::Outcome::Geometry(g),
405        Ok(Err(e)) => massprops::Outcome::Error(e.to_string()),
406        Err(_) => massprops::Outcome::Error("The mass properties could not be computed.".into()),
407    }
408}
409
410/// The shape signature of a preview mesh, or why there is none.
411fn signature_outcome(mesh: &crate::mesh::Mesh) -> shape::Outcome {
412    // A partial STEP preview (faces the kernel could not tessellate are
413    // missing) gets no signature: area, volume and the D2 distribution of
414    // a model with holes would compare it wrongly – a part with half its
415    // faces missing would look like a different, open part. The preview
416    // itself is still shown; the part is left out of the similar part
417    // search (also as a candidate) with this reason instead of a misleading
418    // score. When a later kernel shows every face, `CACHE_VERSION` and
419    // `shape::VERSION` are bumped and the signature is computed then.
420    if let Some((shown, total)) = mesh.faces {
421        return shape::Outcome::Error(format!(
422            "The 3D preview shows only {shown} of {total} faces, so the shape cannot be compared."
423        ));
424    }
425    // a bug in the signature must never cost the preview itself
426    let computed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| shape::compute(mesh)));
427    match computed {
428        Ok(Ok(s)) => shape::Outcome::Signature(s),
429        Ok(Err(e)) => shape::Outcome::Error(e.to_string()),
430        Err(_) => shape::Outcome::Error("The shape signature could not be computed.".into()),
431    }
432}
433
434/// The `teamcraft` binary that runs isolated jobs (`preview-mesh`,
435/// `text-extract`); `None` in tests and other binaries, which run them
436/// in-process. The desktop downloads *with local server* ship the same
437/// binary as `teamcraft-server` ([`crate::localserver::SIDECAR_NAME`]): next
438/// to the app's own `TeamCraft` executable, `teamcraft` would be the same
439/// file name on Windows and macOS.
440pub fn worker_exe() -> Option<PathBuf> {
441    std::env::current_exe()
442        .ok()
443        .filter(|exe| is_worker_name(exe))
444}
445
446fn is_worker_name(exe: &Path) -> bool {
447    exe.file_stem()
448        .and_then(|s| s.to_str())
449        .is_some_and(|s| s == "teamcraft" || s == crate::localserver::SIDECAR_NAME)
450}
451
452/// One-at-a-time turns per key ([`Previews::render`]): who asks for a key
453/// that is being worked on waits for that work instead of repeating it.
454#[derive(Clone, Default)]
455struct Turns(Arc<Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>);
456
457impl Turns {
458    fn place(&self, key: &str) -> Place {
459        let lock = self
460            .0
461            .lock()
462            .unwrap_or_else(|e| e.into_inner())
463            .entry(key.to_string())
464            .or_default()
465            .clone();
466        Place {
467            turns: self.clone(),
468            key: key.to_string(),
469            lock: Some(lock),
470        }
471    }
472
473    #[cfg(test)]
474    fn len(&self) -> usize {
475        self.0.lock().unwrap_or_else(|e| e.into_inner()).len()
476    }
477}
478
479/// A place in the line of a key. The key is forgotten when the last place
480/// in its line is given up – also by a request that went away while waiting.
481struct Place {
482    turns: Turns,
483    key: String,
484    lock: Option<Arc<tokio::sync::Mutex<()>>>,
485}
486
487impl Place {
488    /// Waits until it is this place's turn.
489    async fn turn(&self) -> Option<tokio::sync::OwnedMutexGuard<()>> {
490        match &self.lock {
491            Some(lock) => Some(lock.clone().lock_owned().await),
492            None => None,
493        }
494    }
495}
496
497impl Drop for Place {
498    fn drop(&mut self) {
499        let mut line = self.turns.0.lock().unwrap_or_else(|e| e.into_inner());
500        drop(self.lock.take());
501        // only the map holds the lock now: nobody works on or waits for the key
502        if line
503            .get(&self.key)
504            .is_some_and(|lock| Arc::strong_count(lock) == 1)
505        {
506            line.remove(&self.key);
507        }
508    }
509}
510
511/// What a render holds while it works, given back in this order: the slot,
512/// the turn, then the place in the line (which may forget the key only
513/// after the turn is over).
514struct Rendering {
515    _slot: OwnedSemaphorePermit,
516    _turn: Option<tokio::sync::OwnedMutexGuard<()>>,
517    _place: Place,
518}
519
520/// Cached, isolated conversions (one per server, part of `AppState`).
521pub struct Previews {
522    dir: PathBuf,
523    /// The `teamcraft` binary for isolated conversions; `None` = in-process.
524    worker: Option<PathBuf>,
525    slots: Semaphore,
526    /// Conversions waiting for one of the slots (for `GET /metrics`).
527    waiting: AtomicUsize,
528    limits: PreviewLimits,
529    running: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
530    /// The slots of [`Previews::render`].
531    renders: Arc<Semaphore>,
532    /// One render per cache entry at a time.
533    turns: Turns,
534}
535
536impl Previews {
537    pub fn new(vault: &Vault, limits: PreviewLimits) -> Previews {
538        // Only the real server binary understands `preview-mesh`; test
539        // binaries convert in-process.
540        let worker = worker_exe();
541        Previews {
542            dir: vault.root().join("previews"),
543            worker,
544            slots: Semaphore::new(MAX_PARALLEL),
545            waiting: AtomicUsize::new(0),
546            limits,
547            running: Mutex::new(HashMap::new()),
548            renders: Arc::new(Semaphore::new(MAX_RENDERS)),
549            turns: Turns::default(),
550        }
551    }
552
553    /// Runs CPU-bound `work` on a derived cache entry – drawing a thumbnail,
554    /// composing an assembly preview – off the async runtime: at most
555    /// [`MAX_RENDERS`] at a time and one at a time per `key` (the path of
556    /// the entry). `work` must look in the cache before it does anything, so
557    /// requests that waited for the same `key` answer from what the first
558    /// one stored instead of doing it again. A request that only reads the
559    /// cache does not come here and never waits for a slot.
560    ///
561    /// The slot and the turn stay taken until `work` returns, also when the
562    /// request that started it is gone: a blocking thread cannot be stopped,
563    /// and its work is still running.
564    pub async fn render<T, F>(&self, key: &str, work: F) -> AppResult<T>
565    where
566        T: Send + 'static,
567        F: FnOnce() -> T + Send + 'static,
568    {
569        let place = self.turns.place(key);
570        let turn = place.turn().await;
571        let slot = self
572            .renders
573            .clone()
574            .acquire_owned()
575            .await
576            .map_err(|e| AppError::Other(anyhow::anyhow!("render queue closed: {e}")))?;
577        let held = Rendering {
578            _slot: slot,
579            _turn: turn,
580            _place: place,
581        };
582        tokio::task::spawn_blocking(move || {
583            let _held = held;
584            work()
585        })
586        .await
587        .map_err(|e| AppError::Other(anyhow::anyhow!("render task failed: {e}")))
588    }
589
590    /// The cache directory (`<vault>/previews`), also of the thumbnails.
591    pub fn dir(&self) -> &Path {
592        &self.dir
593    }
594
595    /// The preview queue: conversions running now and conversions waiting
596    /// for a free slot (at most [`MAX_PARALLEL`] run at a time).
597    pub fn queue(&self) -> (usize, usize) {
598        let running = MAX_PARALLEL.saturating_sub(self.slots.available_permits());
599        (running, self.waiting.load(Ordering::Relaxed))
600    }
601
602    /// Waits for a conversion slot; counted in [`Previews::queue`] while
603    /// waiting, also when the waiting request is dropped.
604    async fn slot(&self) -> AppResult<SemaphorePermit<'_>> {
605        struct Waiting<'a>(&'a AtomicUsize);
606        impl Drop for Waiting<'_> {
607            fn drop(&mut self) {
608                self.0.fetch_sub(1, Ordering::Relaxed);
609            }
610        }
611        self.waiting.fetch_add(1, Ordering::Relaxed);
612        let _waiting = Waiting(&self.waiting);
613        self.slots
614            .acquire()
615            .await
616            .map_err(|e| AppError::Other(anyhow::anyhow!("preview queue closed: {e}")))
617    }
618
619    fn paths(&self, sha256: &str) -> AppResult<(PathBuf, PathBuf)> {
620        if sha256.len() != 64 || !sha256.bytes().all(|b| b.is_ascii_hexdigit()) {
621            return Err(AppError::BadRequest("Invalid content hash.".into()));
622        }
623        let base = self.dir.join(&sha256[..2]);
624        Ok((
625            base.join(format!("{sha256}.v{CACHE_VERSION}.tcm")),
626            base.join(format!("{sha256}.v{CACHE_VERSION}.err")),
627        ))
628    }
629
630    /// Where the composed preview of an assembly is cached: next to the
631    /// blob's own preview, named by the components it was built from
632    /// (`key`, hex), so a changed component gives a new file.
633    pub fn assembly_path(&self, sha256: &str, key: &str) -> AppResult<PathBuf> {
634        if key.is_empty() || !key.bytes().all(|b| b.is_ascii_hexdigit()) {
635            return Err(AppError::BadRequest("Invalid assembly key.".into()));
636        }
637        let (mesh, _) = self.paths(sha256)?;
638        Ok(mesh.with_file_name(format!("{sha256}.asm-{key}.v{CACHE_VERSION}.tcm")))
639    }
640
641    /// The cached shape signature (or the reason why there is none).
642    fn signature_path(&self, sha256: &str) -> AppResult<PathBuf> {
643        let (mesh, _) = self.paths(sha256)?;
644        Ok(mesh.with_extension(format!("s{}.json", shape::VERSION)))
645    }
646
647    /// The cached mass properties (or the reason why there are none).
648    fn mass_path(&self, sha256: &str) -> AppResult<PathBuf> {
649        let (mesh, _) = self.paths(sha256)?;
650        Ok(mesh.with_extension(format!("m{}.json", massprops::VERSION)))
651    }
652
653    /// The mass properties of a blob per unit density in model units (see
654    /// `massprops`): from the cache, or computed from the cached preview
655    /// mesh in the isolated preview process (converting the file first if
656    /// needed). Failures are cached like failed previews.
657    pub async fn mass_geometry(
658        &self,
659        vault: &Vault,
660        sha256: &str,
661        format: PreviewFormat,
662    ) -> AppResult<massprops::Geometry> {
663        if format.is_point_cloud() || format.is_result() {
664            return Err(AppError::BadRequest(
665                "Point clouds and result files have no volume.".into(),
666            ));
667        }
668        let path = self.mass_path(sha256)?;
669        if let Some(found) = read_mass(&path).await {
670            return found;
671        }
672        let mesh_path = self.mesh(vault, sha256, format).await?;
673        self.exclusive(format!("{sha256}.mass"), async {
674            if let Some(found) = read_mass(&path).await {
675                return found;
676            }
677            let _slot = self.slot().await?;
678            let dir = path.parent().unwrap_or(&self.dir);
679            let tmp = dir.join(format!(
680                "{sha256}.{}.mass.tmp",
681                uuid::Uuid::new_v4().simple()
682            ));
683            let job = Job {
684                format: CACHED_MESH,
685                input: mesh_path.clone(),
686                output: None,
687                signature: None,
688                mass: Some(tmp.clone()),
689                time_limit: Duration::from_secs(60),
690                max_input_bytes: u64::MAX,
691                memory_bytes: self.limits.max_memory_bytes,
692            };
693            let outcome = match &self.worker {
694                Some(exe) => run_worker(exe, &job).await,
695                None => run_in_process(&job).await,
696            };
697            match outcome {
698                Ok(()) => {
699                    tokio::fs::rename(&tmp, &path).await.map_err(|e| {
700                        AppError::Other(anyhow::anyhow!("cannot store the mass properties: {e}"))
701                    })?;
702                }
703                Err(message) => {
704                    let _ = tokio::fs::remove_file(&tmp).await;
705                    let failed = serde_json::to_vec(&massprops::Outcome::Error(message.clone()))
706                        .unwrap_or_default();
707                    let _ = tokio::fs::write(&path, failed).await;
708                    return Err(AppError::BadRequest(message));
709                }
710            }
711            read_mass(&path).await.unwrap_or_else(|| {
712                Err(AppError::Other(anyhow::anyhow!(
713                    "the mass properties could not be read back"
714                )))
715            })
716        })
717        .await
718    }
719
720    /// Whether the preview mesh of a blob is already cached.
721    pub async fn has_mesh(&self, sha256: &str) -> bool {
722        match self.paths(sha256) {
723            Ok((mesh, _)) => tokio::fs::metadata(mesh).await.is_ok(),
724            Err(_) => false,
725        }
726    }
727
728    /// Whether the conversion of a blob is behind us: its preview mesh or
729    /// the reason why there is none is cached.
730    pub async fn is_converted(&self, sha256: &str) -> bool {
731        match self.paths(sha256) {
732            Ok((mesh, err)) => {
733                tokio::fs::metadata(mesh).await.is_ok() || tokio::fs::metadata(err).await.is_ok()
734            }
735            Err(_) => false,
736        }
737    }
738
739    /// One conversion or signature per key at a time, however many wait.
740    async fn exclusive<T, F>(&self, key: String, job: F) -> T
741    where
742        F: std::future::Future<Output = T>,
743    {
744        let lock = {
745            let mut running = self.running.lock().unwrap_or_else(|e| e.into_inner());
746            running.entry(key.clone()).or_default().clone()
747        };
748        let guard = lock.lock().await;
749        let result = job.await;
750        drop(guard);
751        let mut running = self.running.lock().unwrap_or_else(|e| e.into_inner());
752        // only the last waiter removes the entry
753        if running.get(&key).is_some_and(|l| Arc::strong_count(l) <= 2) {
754            running.remove(&key);
755        }
756        result
757    }
758
759    /// The shape signature of a blob (see `shape`): from the cache, written
760    /// by the conversion, or computed from the cached preview mesh in the
761    /// isolated preview process. Failures are cached like failed previews.
762    pub async fn signature(
763        &self,
764        vault: &Vault,
765        sha256: &str,
766        format: PreviewFormat,
767    ) -> AppResult<shape::Signature> {
768        if format.is_point_cloud() {
769            return Err(AppError::BadRequest(
770                "Point clouds have no shape signature.".into(),
771            ));
772        }
773        let sig_path = self.signature_path(sha256)?;
774        if let Some(found) = read_signature(&sig_path).await {
775            return found;
776        }
777        let mesh_path = self.mesh(vault, sha256, format).await?;
778        self.exclusive(format!("{sha256}.signature"), async {
779            if let Some(found) = read_signature(&sig_path).await {
780                return found;
781            }
782            let _slot = self.slot().await?;
783            let dir = sig_path.parent().unwrap_or(&self.dir);
784            let tmp = dir.join(format!(
785                "{sha256}.{}.sig.tmp",
786                uuid::Uuid::new_v4().simple()
787            ));
788            let job = Job {
789                format: CACHED_MESH,
790                input: mesh_path.clone(),
791                output: None,
792                signature: Some(tmp.clone()),
793                mass: None,
794                time_limit: Duration::from_secs(60),
795                max_input_bytes: u64::MAX,
796                memory_bytes: self.limits.max_memory_bytes,
797            };
798            let outcome = match &self.worker {
799                Some(exe) => run_worker(exe, &job).await,
800                None => run_in_process(&job).await,
801            };
802            match outcome {
803                Ok(()) => {
804                    tokio::fs::rename(&tmp, &sig_path).await.map_err(|e| {
805                        AppError::Other(anyhow::anyhow!("cannot store the shape signature: {e}"))
806                    })?;
807                }
808                Err(message) => {
809                    let _ = tokio::fs::remove_file(&tmp).await;
810                    let failed = serde_json::to_vec(&shape::Outcome::Error(message.clone()))
811                        .unwrap_or_default();
812                    let _ = tokio::fs::write(&sig_path, failed).await;
813                    return Err(AppError::BadRequest(message));
814                }
815            }
816            read_signature(&sig_path).await.unwrap_or_else(|| {
817                Err(AppError::Other(anyhow::anyhow!(
818                    "the shape signature could not be read back"
819                )))
820            })
821        })
822        .await
823    }
824
825    /// The cached mesh of a blob, converting it first if necessary.
826    pub async fn mesh(
827        &self,
828        vault: &Vault,
829        sha256: &str,
830        format: PreviewFormat,
831    ) -> AppResult<PathBuf> {
832        let (mesh_path, err_path) = self.paths(sha256)?;
833        self.check_size(vault, sha256, format, &err_path).await?;
834        if let Some(found) = cached(&mesh_path, &err_path).await? {
835            return Ok(found);
836        }
837        // one conversion per blob, however many viewers wait for it
838        let lock = {
839            let mut running = self.running.lock().unwrap_or_else(|e| e.into_inner());
840            running.entry(sha256.to_string()).or_default().clone()
841        };
842        let guard = lock.lock().await;
843        let result = match cached(&mesh_path, &err_path).await {
844            Ok(Some(found)) => Ok(found),
845            Err(e) => Err(e),
846            Ok(None) => self
847                .convert(vault, sha256, format, &mesh_path, &err_path)
848                .await
849                .map(|_| mesh_path.clone()),
850        };
851        drop(guard);
852        self.running
853            .lock()
854            .unwrap_or_else(|e| e.into_inner())
855            .remove(sha256);
856        result
857    }
858
859    /// A file above the size cap of its kind is refused before anything opens
860    /// it; the refusal is cached like a failed conversion. The cap is checked
861    /// on every call (one `stat`), so raising it takes effect at once.
862    async fn check_size(
863        &self,
864        vault: &Vault,
865        sha256: &str,
866        format: PreviewFormat,
867        err_path: &Path,
868    ) -> AppResult<()> {
869        let Ok(size) = tokio::fs::metadata(vault.object_path(sha256)?)
870            .await
871            .map(|m| m.len())
872        else {
873            return Ok(());
874        };
875        let limit = self.limits.max_bytes(format);
876        if size > limit {
877            let message = too_large_message(size, limit);
878            if let Some(dir) = err_path.parent() {
879                let _ = tokio::fs::create_dir_all(dir).await;
880            }
881            let _ = tokio::fs::write(err_path, &message).await;
882            return Err(AppError::BadRequest(message));
883        }
884        // a refusal from before the cap was raised
885        if let Ok(old) = tokio::fs::read_to_string(err_path).await {
886            if old.starts_with(TOO_LARGE) {
887                let _ = tokio::fs::remove_file(err_path).await;
888            }
889        }
890        Ok(())
891    }
892
893    /// Starts the conversion in the background (after an upload), so the
894    /// first viewer does not have to wait.
895    pub fn warm(self: &Arc<Self>, vault: &Vault, sha256: &str, format: PreviewFormat) {
896        let previews = self.clone();
897        let vault = vault.clone();
898        let sha256 = sha256.to_string();
899        tokio::spawn(async move {
900            if let Err(e) = previews.mesh(&vault, &sha256, format).await {
901                tracing::debug!(sha256, error = %e.public_message(), "preview not generated");
902            }
903        });
904    }
905
906    async fn convert(
907        &self,
908        vault: &Vault,
909        sha256: &str,
910        format: PreviewFormat,
911        mesh_path: &Path,
912        err_path: &Path,
913    ) -> AppResult<()> {
914        let input = vault.object_path(sha256)?;
915        if !vault.exists(sha256).await {
916            return Err(AppError::not_found(
917                "The file content is missing in the vault.",
918            ));
919        }
920        let dir = mesh_path.parent().unwrap_or(&self.dir).to_path_buf();
921        tokio::fs::create_dir_all(&dir).await.map_err(|e| {
922            AppError::Other(anyhow::anyhow!("cannot create {}: {e}", dir.display()))
923        })?;
924        let _slot = self.slot().await?;
925        let tmp = dir.join(format!("{sha256}.{}.tmp", uuid::Uuid::new_v4().simple()));
926        // the shape signature comes with the conversion, from the same mesh
927        let sig_tmp = tmp.with_extension("sig.tmp");
928        let job = Job {
929            format: format.as_str(),
930            input,
931            output: Some(tmp.clone()),
932            signature: (!format.is_point_cloud()).then(|| sig_tmp.clone()),
933            mass: None,
934            time_limit: format.time_limit(),
935            max_input_bytes: self.limits.max_bytes(format),
936            memory_bytes: self.limits.max_memory_bytes,
937        };
938        let started = std::time::Instant::now();
939        let outcome = match &self.worker {
940            Some(exe) => run_worker(exe, &job).await,
941            None => run_in_process(&job).await,
942        };
943        match outcome {
944            Ok(()) => {
945                if job.signature.is_some() {
946                    let sig_path = self.signature_path(sha256)?;
947                    if tokio::fs::rename(&sig_tmp, &sig_path).await.is_err() {
948                        let _ = tokio::fs::remove_file(&sig_tmp).await;
949                    }
950                }
951                tokio::fs::rename(&tmp, mesh_path).await.map_err(|e| {
952                    AppError::Other(anyhow::anyhow!("cannot store the preview: {e}"))
953                })?;
954                tracing::info!(
955                    sha256,
956                    format = format.as_str(),
957                    ms = started.elapsed().as_millis() as u64,
958                    "preview generated"
959                );
960                Ok(())
961            }
962            Err(message) => {
963                let _ = tokio::fs::remove_file(&tmp).await;
964                let _ = tokio::fs::remove_file(&sig_tmp).await;
965                let _ = tokio::fs::write(err_path, &message).await;
966                tracing::info!(sha256, format = format.as_str(), %message, "no preview");
967                Err(AppError::BadRequest(message))
968            }
969        }
970    }
971}
972
973/// The cached mesh, or the cached reason why there is none.
974async fn cached(mesh_path: &Path, err_path: &Path) -> AppResult<Option<PathBuf>> {
975    if tokio::fs::metadata(mesh_path).await.is_ok() {
976        return Ok(Some(mesh_path.to_path_buf()));
977    }
978    match tokio::fs::read_to_string(err_path).await {
979        Ok(message) => Err(AppError::BadRequest(message)),
980        Err(_) => Ok(None),
981    }
982}
983
984/// The cached shape signature: `None` if not computed yet (or unreadable).
985async fn read_signature(path: &Path) -> Option<AppResult<shape::Signature>> {
986    let bytes = tokio::fs::read(path).await.ok()?;
987    match serde_json::from_slice::<shape::Outcome>(&bytes).ok()? {
988        shape::Outcome::Signature(s) if s.is_valid() => Some(Ok(s)),
989        shape::Outcome::Signature(_) => None,
990        shape::Outcome::Error(message) => Some(Err(AppError::BadRequest(message))),
991    }
992}
993
994/// The cached mass properties: `None` if not computed yet (or unreadable).
995async fn read_mass(path: &Path) -> Option<AppResult<massprops::Geometry>> {
996    let bytes = tokio::fs::read(path).await.ok()?;
997    match serde_json::from_slice::<massprops::Outcome>(&bytes).ok()? {
998        massprops::Outcome::Geometry(g) if g.is_valid() => Some(Ok(g)),
999        massprops::Outcome::Geometry(_) => None,
1000        massprops::Outcome::Error(message) => Some(Err(AppError::BadRequest(message))),
1001    }
1002}
1003
1004/// One run of the preview process (see [`run_job`]).
1005struct Job {
1006    format: &'static str,
1007    input: PathBuf,
1008    output: Option<PathBuf>,
1009    signature: Option<PathBuf>,
1010    mass: Option<PathBuf>,
1011    time_limit: Duration,
1012    /// Largest input the process opens (the process checks it again).
1013    max_input_bytes: u64,
1014    /// Address space of the process, 0 = unlimited.
1015    memory_bytes: u64,
1016}
1017
1018/// `teamcraft preview-mesh` in a child process that is killed at the time limit.
1019async fn run_worker(exe: &Path, job: &Job) -> Result<(), String> {
1020    let mut command = tokio::process::Command::new(exe);
1021    command
1022        .arg("preview-mesh")
1023        .arg("--format")
1024        .arg(job.format)
1025        .arg("--input")
1026        .arg(&job.input)
1027        .arg("--max-input-bytes")
1028        .arg(job.max_input_bytes.to_string())
1029        .arg("--max-memory-bytes")
1030        .arg(job.memory_bytes.to_string());
1031    if let Some(output) = &job.output {
1032        command.arg("--output").arg(output);
1033    }
1034    if let Some(signature) = &job.signature {
1035        command.arg("--signature").arg(signature);
1036    }
1037    if let Some(mass) = &job.mass {
1038        command.arg("--mass-properties").arg(mass);
1039    }
1040    let child = command
1041        .env_clear()
1042        .env("RUST_LOG", "error")
1043        .stdin(std::process::Stdio::null())
1044        .stdout(std::process::Stdio::null())
1045        .stderr(std::process::Stdio::piped())
1046        .kill_on_drop(true)
1047        .spawn()
1048        .map_err(|e| format!("The preview converter could not be started: {e}"))?;
1049    match tokio::time::timeout(job.time_limit, child.wait_with_output()).await {
1050        Err(_) => Err(format!(
1051            "The model is too complex to preview (no result within {} s) – download it instead.",
1052            job.time_limit.as_secs()
1053        )),
1054        Ok(Err(e)) => Err(format!("The preview converter failed: {e}")),
1055        Ok(Ok(out)) if out.status.success() => Ok(()),
1056        Ok(Ok(out)) => {
1057            #[cfg(unix)]
1058            let aborted = std::os::unix::process::ExitStatusExt::signal(&out.status) == Some(6);
1059            #[cfg(not(unix))]
1060            let aborted = false;
1061            let stderr = String::from_utf8_lossy(&out.stderr);
1062            Err(describe_failure(aborted, &stderr, job.memory_bytes))
1063        }
1064    }
1065}
1066
1067/// The job on a blocking thread (tests and unusual installations).
1068async fn run_in_process(job: &Job) -> Result<(), String> {
1069    let (format, input, output, signature, mass, max_input) = (
1070        job.format,
1071        job.input.clone(),
1072        job.output.clone(),
1073        job.signature.clone(),
1074        job.mass.clone(),
1075        job.max_input_bytes,
1076    );
1077    let task = tokio::task::spawn_blocking(move || -> Result<(), String> {
1078        run_job(
1079            format,
1080            &input,
1081            output.as_deref(),
1082            signature.as_deref(),
1083            mass.as_deref(),
1084            max_input,
1085        )
1086        .map(drop)
1087        .map_err(|e| e.to_string())
1088    });
1089    match tokio::time::timeout(job.time_limit, task).await {
1090        Err(_) => Err(format!(
1091            "The model is too complex to preview (no result within {} s).",
1092            job.time_limit.as_secs()
1093        )),
1094        Ok(Err(e)) => Err(format!("The preview converter failed: {e}")),
1095        Ok(Ok(result)) => result,
1096    }
1097}
1098
1099/// What a file in the preview cache (`previews/ab/…`) is, by its name.
1100#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1101pub enum CacheEntry<'a> {
1102    /// A cache file of the current versions, for the blob with this SHA-256.
1103    Current(&'a str),
1104    /// A cache file of an older cache, mesh or thumbnail version.
1105    Stale,
1106    /// A file being written (or left behind by an interrupted conversion).
1107    Temporary,
1108    /// Not a name the cache writes: left alone.
1109    Unknown,
1110}
1111
1112/// Classifies a file name of the preview cache: the meshes and failures of
1113/// this module (`<sha256>.v<N>.tcm|err`), their shape signatures
1114/// (`<sha256>.v<N>.s<M>.json`) and the thumbnails of [`crate::thumbnail`]
1115/// (`<sha256>.thumb.v<N>.png|bmp|jpg|none`); every temporary name ends in
1116/// `.tmp`.
1117pub fn cache_entry(name: &str) -> CacheEntry<'_> {
1118    let Some((sha256, rest)) = name.split_at_checked(64) else {
1119        return CacheEntry::Unknown;
1120    };
1121    let Some(rest) = rest.strip_prefix('.') else {
1122        return CacheEntry::Unknown;
1123    };
1124    if !sha256
1125        .bytes()
1126        .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
1127    {
1128        return CacheEntry::Unknown;
1129    }
1130    if rest.ends_with(".tmp") {
1131        return CacheEntry::Temporary;
1132    }
1133    // composed assembly previews: `asm-<hex key>.v<N>.tcm`
1134    if let Some((key, tail)) = rest.strip_prefix("asm-").and_then(|r| r.split_once('.')) {
1135        if !key.is_empty() && key.bytes().all(|b| b.is_ascii_hexdigit()) {
1136            if let Some(n) = tail.strip_prefix('v').and_then(|t| t.strip_suffix(".tcm")) {
1137                if n == CACHE_VERSION.to_string() {
1138                    return CacheEntry::Current(sha256);
1139                }
1140                if !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()) {
1141                    return CacheEntry::Stale;
1142                }
1143            }
1144        }
1145    }
1146    let mesh = format!("v{CACHE_VERSION}.");
1147    let thumb = format!("thumb.v{}.", crate::thumbnail::CACHE_VERSION);
1148    let current = match rest.strip_prefix(&mesh) {
1149        Some(kind) => {
1150            kind == "tcm"
1151                || kind == "err"
1152                || kind == format!("s{}.json", shape::VERSION)
1153                || kind == format!("m{}.json", massprops::VERSION)
1154        }
1155        None => rest
1156            .strip_prefix(&thumb)
1157            .is_some_and(|ext| matches!(ext, "png" | "bmp" | "jpg" | "none")),
1158    };
1159    if current {
1160        return CacheEntry::Current(sha256);
1161    }
1162    // an older version of one of the names above
1163    let version = |s: &str| {
1164        s.strip_prefix('v')
1165            .and_then(|s| s.split_once('.'))
1166            .is_some_and(|(n, _)| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
1167    };
1168    if version(rest) || rest.strip_prefix("thumb.").is_some_and(version) {
1169        CacheEntry::Stale
1170    } else {
1171        CacheEntry::Unknown
1172    }
1173}
1174
1175#[cfg(test)]
1176mod tests {
1177    use super::*;
1178
1179    /// The binary isolates its conversions under both of its names – also
1180    /// as `teamcraft-server` in the desktop downloads with local server.
1181    #[test]
1182    fn worker_names() {
1183        for name in [
1184            "/usr/bin/teamcraft",
1185            "teamcraft.exe",
1186            "/Applications/TeamCraft.app/Contents/MacOS/teamcraft-server",
1187            "teamcraft-server.exe",
1188        ] {
1189            assert!(is_worker_name(Path::new(name)), "{name}");
1190        }
1191        for name in [
1192            "teamcraft-desktop",
1193            "TeamCraft",
1194            "local_server-5e3f",
1195            "deps/teamcraft-1a2b",
1196        ] {
1197            assert!(!is_worker_name(Path::new(name)), "{name}");
1198        }
1199    }
1200
1201    #[test]
1202    fn partial_previews_get_no_shape_signature() {
1203        let mut mesh = crate::mesh::Mesh {
1204            positions: vec![0.0, 0.0, 0.0, 4.0, 0.0, 0.0, 0.0, 3.0, 0.0],
1205            indices: vec![0, 1, 2],
1206            ..Default::default()
1207        };
1208        assert!(matches!(
1209            signature_outcome(&mesh),
1210            shape::Outcome::Signature(_)
1211        ));
1212        mesh.faces = Some((5, 6));
1213        match signature_outcome(&mesh) {
1214            shape::Outcome::Error(message) => assert!(message.contains("5 of 6 faces")),
1215            other => panic!("{other:?}"),
1216        }
1217    }
1218
1219    #[test]
1220    fn failures_of_the_conversion_process_are_explained() {
1221        let limit = 1024 * MIB;
1222        let m = describe_failure(true, "", limit);
1223        assert!(m.contains("more memory than allowed (1024 MB)"), "{m}");
1224        let m = describe_failure(false, "memory allocation of 99 bytes failed", limit);
1225        assert!(m.contains("more memory"), "{m}");
1226        // without a limit an abort is not blamed on it
1227        let m = describe_failure(true, "Error: broken file\n", 0);
1228        assert_eq!(m, "broken file");
1229        assert_eq!(
1230            describe_failure(false, "x\nError: no triangles\n\n", limit),
1231            "no triangles"
1232        );
1233        assert_eq!(
1234            describe_failure(false, "", limit),
1235            "the converter stopped unexpectedly"
1236        );
1237    }
1238
1239    #[test]
1240    fn caps_by_kind() {
1241        let l = PreviewLimits::default();
1242        assert_eq!(l.max_bytes(PreviewFormat::Vtu), 64 * MIB);
1243        assert_eq!(l.max_bytes(PreviewFormat::Frd), 64 * MIB);
1244        assert_eq!(l.max_bytes(PreviewFormat::Stl), 256 * MIB);
1245        assert_eq!(l.max_bytes(PreviewFormat::Las), 256 * MIB);
1246        assert_eq!(l.max_bytes(PreviewFormat::Step), 256 * MIB);
1247        let dir = std::env::temp_dir().join(format!("tc-cap-{}", uuid::Uuid::new_v4()));
1248        std::fs::create_dir_all(&dir).unwrap();
1249        let file = dir.join("big.vtu");
1250        std::fs::File::create(&file)
1251            .unwrap()
1252            .set_len(5 * MIB)
1253            .unwrap();
1254        let e = convert_within(PreviewFormat::Vtu, &file, MIB).unwrap_err();
1255        assert!(e.to_string().contains("too large for a 3D preview"), "{e}");
1256        assert!(e.to_string().contains("5 MB, at most 1 MB"), "{e}");
1257        std::fs::remove_dir_all(&dir).ok();
1258    }
1259
1260    #[test]
1261    fn formats_by_extension() {
1262        assert_eq!(PreviewFormat::of("bracket.STL"), Some(PreviewFormat::Stl));
1263        assert_eq!(PreviewFormat::of("engine.step"), Some(PreviewFormat::Step));
1264        assert_eq!(
1265            PreviewFormat::of("engine.v2.stp"),
1266            Some(PreviewFormat::Step)
1267        );
1268        assert_eq!(PreviewFormat::of("engine.p21"), Some(PreviewFormat::Step));
1269        assert_eq!(PreviewFormat::of("print.3mf"), Some(PreviewFormat::ThreeMf));
1270        assert_eq!(PreviewFormat::of("scene.GLB"), Some(PreviewFormat::Glb));
1271        assert_eq!(PreviewFormat::of("scan.las"), Some(PreviewFormat::Las));
1272        assert_eq!(
1273            PreviewFormat::of("Bracket.SLDPRT"),
1274            Some(PreviewFormat::SwPart)
1275        );
1276        assert_eq!(
1277            PreviewFormat::of("Rover.sldasm"),
1278            Some(PreviewFormat::SwAssembly)
1279        );
1280        assert_eq!(
1281            PreviewFormat::of("Bracket.ipt"),
1282            Some(PreviewFormat::InvPart)
1283        );
1284        assert_eq!(
1285            PreviewFormat::of("Pump.IAM"),
1286            Some(PreviewFormat::InvAssembly)
1287        );
1288        assert_eq!(
1289            PreviewFormat::of("Frame.CATProduct"),
1290            Some(PreviewFormat::CatiaProduct)
1291        );
1292        assert!(PreviewFormat::CatiaProduct.may_be_assembly());
1293        assert_eq!(
1294            PreviewFormat::of("Frame.asm.12"),
1295            Some(PreviewFormat::CreoAssembly)
1296        );
1297        assert!(PreviewFormat::CreoAssembly.may_be_assembly());
1298        assert_eq!(PreviewFormat::of("Bracket.par"), None, "native CAD");
1299        assert_eq!(
1300            PreviewFormat::of("plate.iges"),
1301            None,
1302            "no B-rep reader for IGES"
1303        );
1304        assert_eq!(PreviewFormat::of("drawing.pdf"), None);
1305        assert_eq!(PreviewFormat::of("stl"), None);
1306        assert_eq!(PreviewFormat::of(".stl"), None);
1307        assert!(PreviewFormat::Step.is_brep() && !PreviewFormat::Stl.is_brep());
1308        assert!(PreviewFormat::Las.is_point_cloud());
1309        assert_eq!(PreviewFormat::of("chair.3dm"), Some(PreviewFormat::Rhino));
1310        assert_eq!(PreviewFormat::of("beam.vtu"), Some(PreviewFormat::Vtu));
1311        assert_eq!(
1312            PreviewFormat::of("beam.VTK"),
1313            Some(PreviewFormat::VtkLegacy)
1314        );
1315        assert_eq!(PreviewFormat::of("skin.vtp"), Some(PreviewFormat::Vtp));
1316        assert_eq!(PreviewFormat::of("job.frd"), Some(PreviewFormat::Frd));
1317        assert_eq!(PreviewFormat::of("grid.vts"), None, "structured grids");
1318        assert!(PreviewFormat::Frd.is_result() && !PreviewFormat::Stl.is_result());
1319        for p in PreviewFormat::ALL {
1320            assert_eq!(PreviewFormat::of_format(p.as_str()), Some(p));
1321            assert!(
1322                crate::cadformat::by_id(p.as_str()).is_some(),
1323                "{} is a registered format",
1324                p.as_str()
1325            );
1326        }
1327    }
1328
1329    #[tokio::test]
1330    async fn conversions_are_cached_including_failures() {
1331        let root = std::env::temp_dir().join(format!("teamcraft-preview-{}", uuid::Uuid::new_v4()));
1332        let vault = Vault::open(&root).await.unwrap();
1333        let previews = Previews::new(&vault, PreviewLimits::default());
1334        assert!(previews.worker.is_none(), "tests convert in-process");
1335        let ok = vault
1336            .store_bytes(b"solid t\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid t\n")
1337            .await
1338            .unwrap();
1339        let path = previews
1340            .mesh(&vault, &ok.sha256, PreviewFormat::Stl)
1341            .await
1342            .unwrap();
1343        let mesh = crate::mesh::Mesh::decode(&std::fs::read(&path).unwrap()).unwrap();
1344        assert_eq!(mesh.triangle_count(), 1);
1345        // second call: from the cache
1346        assert_eq!(
1347            previews
1348                .mesh(&vault, &ok.sha256, PreviewFormat::Stl)
1349                .await
1350                .unwrap(),
1351            path
1352        );
1353        let bad = vault.store_bytes(b"this is not a mesh").await.unwrap();
1354        let first = previews.mesh(&vault, &bad.sha256, PreviewFormat::Stl).await;
1355        assert!(matches!(first, Err(AppError::BadRequest(_))));
1356        let (_, err_path) = previews.paths(&bad.sha256).unwrap();
1357        assert!(err_path.exists(), "the failure is remembered");
1358        assert!(previews
1359            .mesh(&vault, &bad.sha256, PreviewFormat::Stl)
1360            .await
1361            .is_err());
1362        assert!(previews.paths("../../etc/passwd").is_err());
1363
1364        // The conversion wrote the shape signature as well.
1365        let sig_path = previews.signature_path(&ok.sha256).unwrap();
1366        assert!(sig_path.exists(), "signature written with the preview");
1367        let sig = previews
1368            .signature(&vault, &ok.sha256, PreviewFormat::Stl)
1369            .await
1370            .unwrap();
1371        assert_eq!(sig.triangles, 1);
1372        assert!(!sig.closed);
1373        // Previews from before signatures: computed from the cached mesh.
1374        std::fs::remove_file(&sig_path).unwrap();
1375        assert!(previews.has_mesh(&ok.sha256).await);
1376        // converted: a mesh or a cached failure, not yet: neither
1377        assert!(previews.is_converted(&ok.sha256).await);
1378        assert!(previews.is_converted(&bad.sha256).await);
1379        assert!(!previews.is_converted(&"0".repeat(64)).await);
1380        let again = previews
1381            .signature(&vault, &ok.sha256, PreviewFormat::Stl)
1382            .await
1383            .unwrap();
1384        assert_eq!(again, sig);
1385        assert!(previews
1386            .signature(&vault, &bad.sha256, PreviewFormat::Stl)
1387            .await
1388            .is_err());
1389        assert!(previews
1390            .signature(&vault, &ok.sha256, PreviewFormat::Xyz)
1391            .await
1392            .is_err());
1393        // A broken cached mesh is an error, not a panic.
1394        let broken = root.join("broken.tcm");
1395        std::fs::write(&broken, b"TCMESH01\xff\xff\xff\xff").unwrap();
1396        assert!(run_job(
1397            CACHED_MESH,
1398            &broken,
1399            None,
1400            Some(&root.join("x.json")),
1401            None,
1402            u64::MAX
1403        )
1404        .is_err());
1405        std::fs::remove_dir_all(&root).ok();
1406    }
1407
1408    #[test]
1409    fn cache_names_are_classified() {
1410        let sha = "ab".repeat(32);
1411        let previews = Previews {
1412            dir: PathBuf::from("/vault/previews"),
1413            worker: None,
1414            slots: Semaphore::new(1),
1415            waiting: AtomicUsize::new(0),
1416            limits: PreviewLimits::default(),
1417            running: Mutex::new(HashMap::new()),
1418            renders: Arc::new(Semaphore::new(1)),
1419            turns: Turns::default(),
1420        };
1421        let (mesh, err) = previews.paths(&sha).unwrap();
1422        let asm = previews.assembly_path(&sha, "0123abcd").unwrap();
1423        let sig = previews.signature_path(&sha).unwrap();
1424        let mass = previews.mass_path(&sha).unwrap();
1425        for path in [&mesh, &err, &sig, &asm, &mass] {
1426            let name = path.file_name().unwrap().to_str().unwrap();
1427            assert_eq!(cache_entry(name), CacheEntry::Current(&sha), "{name}");
1428        }
1429        for ext in ["png", "bmp", "jpg", "none"] {
1430            let name = format!("{sha}.thumb.v{}.{ext}", crate::thumbnail::CACHE_VERSION);
1431            assert_eq!(cache_entry(&name), CacheEntry::Current(&sha), "{name}");
1432        }
1433        let old = CACHE_VERSION + 7;
1434        for name in [
1435            format!("{sha}.v{old}.tcm"),
1436            format!("{sha}.v{old}.err"),
1437            format!("{sha}.v{CACHE_VERSION}.s{}.json", shape::VERSION + 7),
1438            format!("{sha}.v{CACHE_VERSION}.m{}.json", massprops::VERSION + 7),
1439            format!("{sha}.thumb.v{old}.png"),
1440            format!("{sha}.asm-0123abcd.v{old}.tcm"),
1441        ] {
1442            assert_eq!(cache_entry(&name), CacheEntry::Stale, "{name}");
1443        }
1444        for name in [
1445            format!("{sha}.0123abcd.tmp"),
1446            format!("{sha}.0123abcd.sig.tmp"),
1447            format!("{sha}.thumb.v{CACHE_VERSION}.png.4711.tmp"),
1448        ] {
1449            assert_eq!(cache_entry(&name), CacheEntry::Temporary, "{name}");
1450        }
1451        for name in [
1452            "README".to_string(),
1453            format!("{}.v2.tcm", "AB".repeat(32)),
1454            format!("{sha}v2.tcm"),
1455            format!("{sha}.notes.txt"),
1456            format!("{}.v2.tcm", "ab".repeat(31)),
1457            "é".repeat(40),
1458        ] {
1459            assert_eq!(cache_entry(&name), CacheEntry::Unknown, "{name}");
1460        }
1461    }
1462
1463    #[tokio::test]
1464    async fn the_queue_counts_running_and_waiting_conversions() {
1465        let root = std::env::temp_dir().join(format!("teamcraft-preview-{}", uuid::Uuid::new_v4()));
1466        let vault = Vault::open(&root).await.unwrap();
1467        let previews = Arc::new(Previews::new(&vault, PreviewLimits::default()));
1468        assert_eq!(previews.queue(), (0, 0));
1469        let held: Vec<_> = take_every_slot(&previews).await;
1470        assert_eq!(previews.queue(), (MAX_PARALLEL, 0));
1471        let waiter = {
1472            let previews = previews.clone();
1473            tokio::spawn(async move {
1474                let _slot = previews.slot().await.unwrap();
1475            })
1476        };
1477        while previews.queue().1 == 0 {
1478            tokio::task::yield_now().await;
1479        }
1480        assert_eq!(previews.queue(), (MAX_PARALLEL, 1));
1481        // a dropped waiter leaves the queue as well
1482        waiter.abort();
1483        let _ = waiter.await;
1484        assert_eq!(previews.queue(), (MAX_PARALLEL, 0));
1485        drop(held);
1486        assert_eq!(previews.queue(), (0, 0));
1487        std::fs::remove_dir_all(&root).ok();
1488    }
1489
1490    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1491    async fn renders_are_bounded_and_done_once_per_key() {
1492        let root = std::env::temp_dir().join(format!("teamcraft-preview-{}", uuid::Uuid::new_v4()));
1493        let vault = Vault::open(&root).await.unwrap();
1494        let previews = Arc::new(Previews::new(&vault, PreviewLimits::default()));
1495        let cache = Arc::new(Mutex::new(std::collections::HashSet::<String>::new()));
1496        let (running, peak) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
1497        let mut requests = Vec::new();
1498        // three requests for each of six entries, all at once
1499        for n in 0..18 {
1500            let (previews, cache) = (previews.clone(), cache.clone());
1501            let (running, peak) = (running.clone(), peak.clone());
1502            requests.push(tokio::spawn(async move {
1503                let key = format!("entry-{}", n % 6);
1504                let work_key = key.clone();
1505                previews
1506                    .render(&key, move || {
1507                        if cache.lock().unwrap().contains(&work_key) {
1508                            return false;
1509                        }
1510                        let now = running.fetch_add(1, Ordering::SeqCst) + 1;
1511                        peak.fetch_max(now, Ordering::SeqCst);
1512                        std::thread::sleep(Duration::from_millis(30));
1513                        cache.lock().unwrap().insert(work_key);
1514                        running.fetch_sub(1, Ordering::SeqCst);
1515                        true
1516                    })
1517                    .await
1518                    .unwrap()
1519            }));
1520        }
1521        let mut rendered = 0;
1522        for request in requests {
1523            rendered += usize::from(request.await.unwrap());
1524        }
1525        // one render per entry, the other requests found it in the cache
1526        assert_eq!(rendered, 6);
1527        let peak = peak.load(Ordering::SeqCst);
1528        assert!((1..=MAX_RENDERS).contains(&peak), "{peak} at once");
1529        assert_eq!(previews.renders.available_permits(), MAX_RENDERS);
1530        assert_eq!(previews.turns.len(), 0);
1531        std::fs::remove_dir_all(&root).ok();
1532    }
1533
1534    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1535    async fn a_render_keeps_its_slot_and_turn_when_the_request_is_gone() {
1536        let root = std::env::temp_dir().join(format!("teamcraft-preview-{}", uuid::Uuid::new_v4()));
1537        let vault = Vault::open(&root).await.unwrap();
1538        let previews = Arc::new(Previews::new(&vault, PreviewLimits::default()));
1539        let (started_tx, started_rx) = std::sync::mpsc::channel::<()>();
1540        let (finish_tx, finish_rx) = std::sync::mpsc::channel::<()>();
1541        let request = {
1542            let previews = previews.clone();
1543            tokio::spawn(async move {
1544                previews
1545                    .render("entry", move || {
1546                        started_tx.send(()).unwrap();
1547                        finish_rx.recv().unwrap();
1548                    })
1549                    .await
1550            })
1551        };
1552        tokio::task::spawn_blocking(move || started_rx.recv().unwrap())
1553            .await
1554            .unwrap();
1555        // a second request for the entry waits in line …
1556        let waiter = {
1557            let previews = previews.clone();
1558            tokio::spawn(async move { previews.render("entry", || ()).await })
1559        };
1560        request.abort();
1561        let _ = request.await;
1562        tokio::time::sleep(Duration::from_millis(50)).await;
1563        // … and the work goes on without its request: slot and turn are taken
1564        assert_eq!(previews.renders.available_permits(), MAX_RENDERS - 1);
1565        assert_eq!(previews.turns.len(), 1);
1566        assert!(!waiter.is_finished());
1567        // a waiter that goes away leaves the line, the entry stays known
1568        waiter.abort();
1569        let _ = waiter.await;
1570        assert_eq!(previews.turns.len(), 1);
1571        finish_tx.send(()).unwrap();
1572        for _ in 0..200 {
1573            if previews.turns.len() == 0 {
1574                break;
1575            }
1576            tokio::time::sleep(Duration::from_millis(10)).await;
1577        }
1578        assert_eq!(previews.renders.available_permits(), MAX_RENDERS);
1579        assert_eq!(previews.turns.len(), 0);
1580        std::fs::remove_dir_all(&root).ok();
1581    }
1582
1583    async fn take_every_slot(previews: &Previews) -> Vec<SemaphorePermit<'_>> {
1584        let mut held = Vec::new();
1585        for _ in 0..MAX_PARALLEL {
1586            held.push(previews.slot().await.unwrap());
1587        }
1588        held
1589    }
1590}