Skip to main content

bambu_rs/server/
files.rs

1//! File access (the printer's storage) — list + upload, behind a seam so the API
2//! is testable without a printer: tests and `--fake` use [`FakeFiles`]; live mode
3//! uses [`LiveFiles`] (FTPS). Listing is a read (open); upload is a write
4//! (password-gated). Both are blocking — call from `spawn_blocking`.
5
6use std::collections::HashMap;
7use std::path::Path;
8use std::sync::Mutex;
9use std::time::{Duration, Instant};
10
11use crate::config::ResolvedTarget;
12use crate::ftp::{FileEntry, FtpsClient};
13
14/// How long a directory listing stays fresh before re-fetching over FTPS. Bounds
15/// the per-poll FTPS connect load (the UI auto-refreshes); writes invalidate it.
16const LIST_TTL: Duration = Duration::from_secs(6);
17
18/// Something that can list and upload files on the printer, and fetch the plate
19/// preview embedded in a sliced `.3mf`.
20pub trait FileStore: Send + Sync {
21    /// List a directory (with dir/size info).
22    fn list(&self, dir: &str) -> Result<Vec<FileEntry>, String>;
23    /// Upload the already-staged `local` file to `remote_path`.
24    fn upload(&self, remote_path: &str, local: &Path) -> Result<(), String>;
25    /// The embedded plate preview PNG for a sliced `.3mf` (`Metadata/plate_N.png`),
26    /// or `None` when the file has no such thumbnail.
27    fn thumbnail(&self, remote_path: &str, plate: u32) -> Result<Option<Vec<u8>>, String>;
28    /// Fetch a file's raw bytes (for the 3D viewer). Capped at [`RAW_MAX`].
29    fn fetch(&self, remote_path: &str) -> Result<Vec<u8>, String>;
30    /// Extract the plate's gcode (`Metadata/plate_N.gcode`) from a `.3mf` — the
31    /// toolpath the 3D viewer renders for a sliced file. `None` if absent.
32    fn gcode(&self, remote_path: &str, plate: u32) -> Result<Option<Vec<u8>>, String>;
33    /// Extract the object mesh model XML(s) from a `.3mf` — the geometry the 3D
34    /// viewer renders as a solid mesh. Bambu stores meshes in external component
35    /// files (`3D/Objects/*.model`) that three's 3MF loader won't follow, so we
36    /// pull them out ourselves. Empty when the file embeds no mesh.
37    fn models(&self, remote_path: &str) -> Result<Vec<String>, String>;
38}
39
40/// Cap on total mesh-XML bytes returned for one `.3mf` (the viewer buffers them).
41const MODELS_MAX: usize = 48 * 1024 * 1024;
42
43/// Extract every `3D/**/*.model` entry that carries a `<mesh>` (the geometry).
44/// The root `3D/3dmodel.model` is only component references — no `<mesh>` — so it
45/// is skipped; the actual meshes live in `3D/Objects/*.model`.
46fn extract_models(zip_bytes: &[u8]) -> Result<Vec<String>, String> {
47    use std::io::Read;
48    let mut archive =
49        zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).map_err(|e| e.to_string())?;
50    let mut out = Vec::new();
51    let mut total = 0usize;
52    for i in 0..archive.len() {
53        let mut entry = archive.by_index(i).map_err(|e| e.to_string())?;
54        let name = entry.name().to_string();
55        if !(name.starts_with("3D/") && name.ends_with(".model")) {
56            continue;
57        }
58        let mut buf = String::new();
59        if entry.read_to_string(&mut buf).is_err() {
60            continue; // not UTF-8 / unreadable — skip
61        }
62        if !buf.contains("<mesh") {
63            continue; // a references-only model (e.g. the root) has no geometry
64        }
65        total += buf.len();
66        if total > MODELS_MAX {
67            break;
68        }
69        out.push(buf);
70    }
71    Ok(out)
72}
73
74/// Extract `Metadata/plate_{plate}.gcode` (the sliced toolpath) from a `.3mf`.
75fn extract_gcode(zip_bytes: &[u8], plate: u32) -> Result<Option<Vec<u8>>, String> {
76    use std::io::Read;
77    let mut archive =
78        zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).map_err(|e| e.to_string())?;
79    let mut entry = match archive.by_name(&format!("Metadata/plate_{plate}.gcode")) {
80        Ok(e) => e,
81        Err(_) => return Ok(None),
82    };
83    let mut buf = Vec::new();
84    entry.read_to_end(&mut buf).map_err(|e| e.to_string())?;
85    Ok((!buf.is_empty()).then_some(buf))
86}
87
88/// Cap for [`FileStore::fetch`] — the whole file is buffered for the viewer.
89pub const RAW_MAX: u64 = 64 * 1024 * 1024;
90
91/// Extract `Metadata/plate_{plate}.png` (the slicer's plate preview) from a
92/// `.3mf` (a zip). Returns `None` if the entry is absent/empty.
93fn extract_thumbnail(zip_bytes: &[u8], plate: u32) -> Result<Option<Vec<u8>>, String> {
94    use std::io::Read;
95    let mut archive =
96        zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).map_err(|e| e.to_string())?;
97    for name in [
98        format!("Metadata/plate_{plate}.png"),
99        format!("Metadata/plate_{plate}_small.png"),
100    ] {
101        if let Ok(mut entry) = archive.by_name(&name) {
102            let mut buf = Vec::new();
103            entry.read_to_end(&mut buf).map_err(|e| e.to_string())?;
104            if !buf.is_empty() {
105                return Ok(Some(buf));
106            }
107        }
108    }
109    Ok(None)
110}
111
112/// Real printer storage over implicit FTPS.
113pub struct LiveFiles {
114    target: ResolvedTarget,
115    /// Cache of extracted thumbnails (key `path#plate`). Pulling a preview means
116    /// downloading the whole `.3mf` over FTPS, so cache it — otherwise repeated
117    /// list renders re-download every file and thumbnails flicker / time out.
118    thumb_cache: Mutex<HashMap<String, Option<Vec<u8>>>>,
119    /// Short-TTL cache of directory listings (key = dir), to bound FTPS connects
120    /// under the UI's auto-refresh.
121    list_cache: Mutex<HashMap<String, (Instant, Vec<FileEntry>)>>,
122    /// Cache of extracted plate gcode (key `path#plate`). Like thumbnails, this
123    /// means downloading the whole `.3mf` over FTPS, so cache it; only modestly
124    /// sized toolpaths are kept (see [`GCODE_CACHE_MAX`]).
125    gcode_cache: Mutex<HashMap<String, Option<Vec<u8>>>>,
126    /// Cache of extracted mesh model XML(s) (key = path), same rationale as the
127    /// gcode cache; only modestly sized meshes are kept.
128    models_cache: Mutex<HashMap<String, Vec<String>>>,
129}
130
131/// Don't cache plate gcode larger than this (a big print's toolpath is many MB;
132/// the viewer only opens one at a time, so the win is re-opens, not memory).
133const GCODE_CACHE_MAX: usize = 8 * 1024 * 1024;
134
135impl LiveFiles {
136    pub fn new(target: ResolvedTarget) -> Self {
137        Self {
138            target,
139            thumb_cache: Mutex::new(HashMap::new()),
140            list_cache: Mutex::new(HashMap::new()),
141            gcode_cache: Mutex::new(HashMap::new()),
142            models_cache: Mutex::new(HashMap::new()),
143        }
144    }
145}
146
147impl FileStore for LiveFiles {
148    fn list(&self, dir: &str) -> Result<Vec<FileEntry>, String> {
149        if let Some((at, entries)) = self
150            .list_cache
151            .lock()
152            .unwrap_or_else(|e| e.into_inner())
153            .get(dir)
154            && at.elapsed() < LIST_TTL
155        {
156            return Ok(entries.clone());
157        }
158        let entries = FtpsClient::new(self.target.clone())
159            .list_entries(dir)
160            .map_err(|e| e.to_string())?;
161        let mut cache = self.list_cache.lock().unwrap_or_else(|e| e.into_inner());
162        if cache.len() >= 64 {
163            cache.clear();
164        }
165        cache.insert(dir.to_string(), (Instant::now(), entries.clone()));
166        Ok(entries)
167    }
168
169    fn upload(&self, remote_path: &str, local: &Path) -> Result<(), String> {
170        FtpsClient::new(self.target.clone())
171            .upload(local, remote_path)
172            .map(|_| ())
173            .map_err(|e| e.to_string())?;
174        // The directory changed — drop cached listings so the new file shows.
175        self.list_cache
176            .lock()
177            .unwrap_or_else(|e| e.into_inner())
178            .clear();
179        Ok(())
180    }
181
182    fn thumbnail(&self, remote_path: &str, plate: u32) -> Result<Option<Vec<u8>>, String> {
183        let key = format!("{remote_path}#{plate}");
184        // Recover from a poisoned lock rather than panicking every later request.
185        if let Some(hit) = self
186            .thumb_cache
187            .lock()
188            .unwrap_or_else(|e| e.into_inner())
189            .get(&key)
190        {
191            return Ok(hit.clone());
192        }
193        let tmp = tempfile::Builder::new()
194            .prefix("bambu-thumb-")
195            .tempfile()
196            .map_err(|e| e.to_string())?;
197        FtpsClient::new(self.target.clone())
198            .download(remote_path, tmp.path())
199            .map_err(|e| e.to_string())?;
200        let bytes = std::fs::read(tmp.path()).map_err(|e| e.to_string())?;
201        let thumb = extract_thumbnail(&bytes, plate)?;
202        let mut cache = self.thumb_cache.lock().unwrap_or_else(|e| e.into_inner());
203        // Bound the cache — keys are caller-controlled on an open endpoint.
204        if cache.len() >= 128 {
205            cache.clear();
206        }
207        cache.insert(key, thumb.clone());
208        Ok(thumb)
209    }
210
211    fn fetch(&self, remote_path: &str) -> Result<Vec<u8>, String> {
212        let tmp = tempfile::Builder::new()
213            .prefix("bambu-raw-")
214            .tempfile()
215            .map_err(|e| e.to_string())?;
216        FtpsClient::new(self.target.clone())
217            .download(remote_path, tmp.path())
218            .map_err(|e| e.to_string())?;
219        let meta = std::fs::metadata(tmp.path()).map_err(|e| e.to_string())?;
220        if meta.len() > RAW_MAX {
221            return Err(format!("file too large to view ({} bytes)", meta.len()));
222        }
223        std::fs::read(tmp.path()).map_err(|e| e.to_string())
224    }
225
226    fn gcode(&self, remote_path: &str, plate: u32) -> Result<Option<Vec<u8>>, String> {
227        let key = format!("{remote_path}#{plate}");
228        if let Some(hit) = self
229            .gcode_cache
230            .lock()
231            .unwrap_or_else(|e| e.into_inner())
232            .get(&key)
233        {
234            return Ok(hit.clone());
235        }
236        let tmp = tempfile::Builder::new()
237            .prefix("bambu-gcode-")
238            .tempfile()
239            .map_err(|e| e.to_string())?;
240        FtpsClient::new(self.target.clone())
241            .download(remote_path, tmp.path())
242            .map_err(|e| e.to_string())?;
243        let bytes = std::fs::read(tmp.path()).map_err(|e| e.to_string())?;
244        let gcode = extract_gcode(&bytes, plate)?;
245        // Only cache modest toolpaths (keys are caller-controlled on an open
246        // endpoint); always bound the entry count.
247        if gcode.as_ref().is_none_or(|g| g.len() <= GCODE_CACHE_MAX) {
248            let mut cache = self.gcode_cache.lock().unwrap_or_else(|e| e.into_inner());
249            if cache.len() >= 16 {
250                cache.clear();
251            }
252            cache.insert(key, gcode.clone());
253        }
254        Ok(gcode)
255    }
256
257    fn models(&self, remote_path: &str) -> Result<Vec<String>, String> {
258        if let Some(hit) = self
259            .models_cache
260            .lock()
261            .unwrap_or_else(|e| e.into_inner())
262            .get(remote_path)
263        {
264            return Ok(hit.clone());
265        }
266        let tmp = tempfile::Builder::new()
267            .prefix("bambu-mesh-")
268            .tempfile()
269            .map_err(|e| e.to_string())?;
270        FtpsClient::new(self.target.clone())
271            .download(remote_path, tmp.path())
272            .map_err(|e| e.to_string())?;
273        let bytes = std::fs::read(tmp.path()).map_err(|e| e.to_string())?;
274        let models = extract_models(&bytes)?;
275        // Only cache modest meshes (keys are caller-controlled); bound the count.
276        let total: usize = models.iter().map(String::len).sum();
277        if total <= GCODE_CACHE_MAX {
278            let mut cache = self.models_cache.lock().unwrap_or_else(|e| e.into_inner());
279            if cache.len() >= 16 {
280                cache.clear();
281            }
282            cache.insert(remote_path.to_string(), models.clone());
283        }
284        Ok(models)
285    }
286}
287
288/// A canned file store for `--fake` mode and tests.
289pub struct FakeFiles;
290
291impl FileStore for FakeFiles {
292    fn list(&self, _dir: &str) -> Result<Vec<FileEntry>, String> {
293        let entry = |name: &str, is_dir: bool, size: u64| FileEntry {
294            name: name.to_string(),
295            is_dir,
296            size,
297        };
298        Ok(vec![
299            entry("cache", true, 0),
300            entry("timelapse", true, 0),
301            entry("coin2c.gcode.3mf", false, 184_320),
302            entry("benchy_2c.3mf", false, 256_000),
303        ])
304    }
305    fn upload(&self, _remote_path: &str, _local: &Path) -> Result<(), String> {
306        Ok(())
307    }
308    fn thumbnail(&self, _remote_path: &str, _plate: u32) -> Result<Option<Vec<u8>>, String> {
309        // A tiny valid PNG so the UI/E2E has something to render in --fake mode.
310        Ok(Some(TINY_PNG.to_vec()))
311    }
312    fn fetch(&self, _remote_path: &str) -> Result<Vec<u8>, String> {
313        // Not a real 3mf; the viewer surfaces a load error in --fake mode.
314        Ok(b"fake-model".to_vec())
315    }
316    fn gcode(&self, _remote_path: &str, _plate: u32) -> Result<Option<Vec<u8>>, String> {
317        // A tiny sample toolpath (a few stacked square perimeters) so the viewer
318        // and E2E render a real, non-empty model in --fake mode.
319        Ok(Some(fake_gcode().into_bytes()))
320    }
321    fn models(&self, _remote_path: &str) -> Result<Vec<String>, String> {
322        // A unit cube as a 3MF mesh model, so the viewer/E2E render a real solid
323        // mesh in --fake mode.
324        Ok(vec![FAKE_CUBE_MODEL.to_string()])
325    }
326}
327
328/// A 10 mm cube as a minimal 3MF mesh model (8 vertices, 12 triangles) — the
329/// `--fake` mesh the viewer renders.
330const FAKE_CUBE_MODEL: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
331<model unit="millimeter" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">
332 <resources><object id="1" type="model"><mesh>
333  <vertices>
334   <vertex x="0" y="0" z="0"/><vertex x="10" y="0" z="0"/><vertex x="10" y="10" z="0"/><vertex x="0" y="10" z="0"/>
335   <vertex x="0" y="0" z="10"/><vertex x="10" y="0" z="10"/><vertex x="10" y="10" z="10"/><vertex x="0" y="10" z="10"/>
336  </vertices>
337  <triangles>
338   <triangle v1="0" v2="2" v3="1"/><triangle v1="0" v2="3" v3="2"/>
339   <triangle v1="4" v2="5" v3="6"/><triangle v1="4" v2="6" v3="7"/>
340   <triangle v1="0" v2="1" v3="5"/><triangle v1="0" v2="5" v3="4"/>
341   <triangle v1="1" v2="2" v3="6"/><triangle v1="1" v2="6" v3="5"/>
342   <triangle v1="2" v2="3" v3="7"/><triangle v1="2" v2="7" v3="6"/>
343   <triangle v1="3" v2="0" v3="4"/><triangle v1="3" v2="4" v3="7"/>
344  </triangles>
345 </mesh></object></resources>
346</model>"#;
347
348/// A small valid gcode toolpath: 6 layers of a 20 mm square perimeter, with
349/// extrusion moves so [`GCodeLoader`] draws extruded (not travel) segments.
350fn fake_gcode() -> String {
351    let mut s = String::from("; fake sample toolpath\nG21\nG90\nM82\n");
352    let mut e = 0.0_f64;
353    for layer in 0..6 {
354        let z = 0.2 * (layer as f64 + 1.0);
355        s.push_str(&format!("G1 Z{z:.2} F600\nG1 X10 Y10 F3000\n"));
356        for &(x, y) in &[(30.0, 10.0), (30.0, 30.0), (10.0, 30.0), (10.0, 10.0)] {
357            e += 1.0;
358            s.push_str(&format!("G1 X{x:.1} Y{y:.1} E{e:.3} F1200\n"));
359        }
360    }
361    s
362}
363
364/// A 1×1 PNG (the `--fake` placeholder preview).
365const TINY_PNG: &[u8] = &[
366    0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
367    0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
368    0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
369    0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0x18, 0xdd, 0x8d, 0xb0, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
370    0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
371];
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn extracts_plate_thumbnail_from_a_3mf_zip() {
379        // Build a minimal zip with Metadata/plate_1.png.
380        use std::io::Write;
381        let mut buf = Vec::new();
382        {
383            let mut zw = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
384            zw.start_file::<_, ()>("Metadata/plate_1.png", zip::write::FileOptions::default())
385                .unwrap();
386            zw.write_all(TINY_PNG).unwrap();
387            zw.finish().unwrap();
388        }
389        assert_eq!(
390            extract_thumbnail(&buf, 1).unwrap().as_deref(),
391            Some(TINY_PNG)
392        );
393        assert_eq!(extract_thumbnail(&buf, 2).unwrap(), None); // no plate 2
394    }
395
396    #[test]
397    fn extracts_plate_gcode_from_a_3mf_zip() {
398        use std::io::Write;
399        let mut buf = Vec::new();
400        {
401            let mut zw = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
402            zw.start_file::<_, ()>("Metadata/plate_1.gcode", zip::write::FileOptions::default())
403                .unwrap();
404            zw.write_all(b"G1 X0 Y0\nG1 X10 Y10 E1\n").unwrap();
405            zw.finish().unwrap();
406        }
407        assert!(
408            extract_gcode(&buf, 1)
409                .unwrap()
410                .unwrap()
411                .starts_with(b"G1 X0 Y0")
412        );
413        assert_eq!(extract_gcode(&buf, 2).unwrap(), None); // no plate 2
414    }
415
416    #[test]
417    fn fake_gcode_is_parseable_extruding_toolpath() {
418        let g = String::from_utf8(fake_gcode().into_bytes()).unwrap();
419        assert!(g.contains("G1 X30.0 Y10.0 E"));
420    }
421
422    #[test]
423    fn extracts_only_mesh_bearing_models_from_a_3mf_zip() {
424        use std::io::Write;
425        let mut buf = Vec::new();
426        {
427            let mut zw = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
428            // Root model: component references only, no <mesh> — must be skipped.
429            zw.start_file::<_, ()>("3D/3dmodel.model", zip::write::FileOptions::default())
430                .unwrap();
431            zw.write_all(
432                b"<model><resources><object id=\"2\"><components/></object></resources></model>",
433            )
434            .unwrap();
435            // Object model: carries the actual <mesh> — must be returned.
436            zw.start_file::<_, ()>(
437                "3D/Objects/part_1.model",
438                zip::write::FileOptions::default(),
439            )
440            .unwrap();
441            zw.write_all(b"<model><resources><object id=\"1\"><mesh><vertices/></mesh></object></resources></model>")
442                .unwrap();
443            zw.finish().unwrap();
444        }
445        let models = extract_models(&buf).unwrap();
446        assert_eq!(models.len(), 1);
447        assert!(models[0].contains("<mesh"));
448    }
449
450    #[test]
451    fn fake_cube_model_is_a_valid_mesh() {
452        let models = FakeFiles.models("/x.3mf").unwrap();
453        assert_eq!(models.len(), 1);
454        assert!(models[0].contains("<vertex "));
455        assert!(models[0].contains("<triangle "));
456    }
457}