Save as mesh: one dialog for STL and 3MF, with Send to slicer (#1272)

One docked Save as mesh dialog replaces Export STL / 3MF / 3MF with face tags, with an optional Send to slicer that lists the slicers installed on this machine. Engine: file.save_mesh and file.slicers; the Convert Mesh dialog goes (mesh.convert and the MeshConvert feature stay, so existing designs open). Strings in all catalogs; screenshots in the PR. Checked on main + this branch: cargo check --workspace, the engine slicer/mesh/file tests, the UI catalog and menu tests and scenario s14 (the one local colour-token failure is epaint's TexturesDelta panic, which main shows on this machine too).
This commit is contained in:
froglemonade2 authored and GitHub committed 2026-10-15 03:51:28 -04:00
1 parent 3e13eb6392
commit 0872eb959a
28 files changed
+1483 -100

No files matched your search

+45 -5
View File
@@ -46,7 +46,14 @@ pub static COMMANDS: &[CommandSpec] = &[
.icon("export")
.noundo()
.params("path; format?: stl|stla|obj|step|3mf|3mf-tagged|urdf|json (default from extension; 3mf-tagged: 3MF whose triangles carry their B-rep face, at full precision, for meshing and analysis tools; docs/formats/3mf-face-tags.md; urdf: the whole assembly as a robot, with STL meshes written next to it; docs/urdf.md); bodies?: [names] (JSON and URDF export the complete design); urdf only: mesh_prefix? (before each mesh file name, e.g. package://robot/meshes/), effort? (default 1000), velocity? (default 10)"),
CommandSpec::new("file.save_mesh", "Save As Mesh", save_stl).icon("export").noundo().params("path; bodies?: [names]; ascii?: bool"),
CommandSpec::new("file.save_mesh", "Save As Mesh", save_mesh)
.at("MESH", "MODIFY")
.icon("export")
.noundo()
.params("path; format?: stl|3mf|3mf-tagged (default from the extension, else stl; 3mf-tagged: docs/formats/3mf-face-tags.md); bodies?: [names] (default: all); ascii?: bool (STL); slicer?: id, name or program path from file.slicers (open the saved file there)"),
CommandSpec::new("file.slicers", "Installed Slicers", slicers)
.noundo()
.params("→ the slicers found on this machine: [{id, name, icon?}]; pass an id or name as file.save_mesh's `slicer`"),
];
fn new_doc(s: &mut Session, p: &Value) -> Result<Value> {
@@ -450,10 +457,43 @@ fn export_urdf(s: &Session, path: &str, p: &Value) -> Result<Value> {
)
}
fn save_stl(s: &mut Session, p: &Value) -> Result<Value> {
let path = path_arg(p, "file.save_mesh")?;
let f = if bool_(p, "ascii").unwrap_or(false) { Format::StlAscii } else { Format::StlBinary };
export_to(s, path, f, &string_list(p, "bodies"))
fn save_mesh(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "file.save_mesh";
let path = path_arg(p, cmd)?;
let format = match str_(p, "format").map(str::to_ascii_lowercase).as_deref() {
None => match std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).as_deref() {
Some("3mf") => Format::ThreeMf,
_ => Format::StlBinary,
},
Some("stl") if bool_(p, "ascii").unwrap_or(false) => Format::StlAscii,
Some("stl") => Format::StlBinary,
Some("3mf") => Format::ThreeMf,
Some("3mf-tagged") => Format::ThreeMfTagged,
Some(other) => return Err(bad(cmd, format!("unknown mesh format `{other}` (stl, 3mf, 3mf-tagged)"))),
};
let slicer = match str_(p, "slicer") {
None => None,
Some(spec) => Some(
crate::slicers::resolve(spec).ok_or_else(|| bad(cmd, format!("no slicer `{spec}` (see file.slicers, or give the path of a program)")))?,
),
};
let mut out = export_to(s, path, format, &string_list(p, "bodies"))?;
if let (Some(slicer), Some(obj)) = (slicer, out.as_object_mut()) {
// The file is saved either way; a slicer that won't start is reported, not fatal.
match slicer.open(path) {
Ok(()) => obj.insert("slicer".into(), json!(slicer.name)),
Err(e) => obj.insert("slicer_error".into(), json!(e)),
};
}
Ok(out)
}
fn slicers(_: &mut Session, _: &Value) -> Result<Value> {
let list: Vec<Value> = crate::slicers::detect()
.iter()
.map(|d| json!({"id": d.id, "name": d.name, "icon": d.icon.as_ref().map(|i| i.path().to_string_lossy().into_owned())}))
.collect();
Ok(json!({ "slicers": list }))
}
fn recovery_dir(p: &Value, cmd: &str) -> Result<std::path::PathBuf> {
-1
View File
@@ -15,7 +15,6 @@ use crate::{
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("mesh.convert", "Convert Mesh", convert)
.at("MESH", "MODIFY")
.icon("import")
.params("body: mesh body name; tolerance?: length expression (default 0.05 mm): reconstruct a closed constant-section mechanical mesh as editable planar and cylindrical faces, with a parametric timeline feature; name?"),
];
+2
View File
@@ -19,6 +19,8 @@ pub mod product_scene;
pub mod recovery;
pub mod render_jobs;
pub mod sample;
mod slicer_icon;
pub mod slicers;
pub mod view;
use std::sync::Arc;
+271
View File
@@ -0,0 +1,271 @@
//! Application icons of installed slicers, read from the application itself: PNG files, macOS
//! `.icns` files and the icon resources of Windows programs. Everything read is untrusted:
//! offsets and sizes are checked and capped, and nothing panics.
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
const PNG_MAGIC: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
/// Largest icon image taken from a file.
const MAX_ICON_BYTES: usize = 1 << 20;
/// Most resource directory entries looked at in one directory.
const MAX_RES_ENTRIES: usize = 512;
pub fn read_capped(path: &Path, max: u64) -> Option<Vec<u8>> {
let file = std::fs::File::open(path).ok()?;
let mut bytes = Vec::new();
file.take(max.checked_add(1)?).read_to_end(&mut bytes).ok()?;
(bytes.len() as u64 <= max).then_some(bytes)
}
/// The largest PNG image inside an `.icns` file (up to a size cap).
pub fn icns_png(data: &[u8]) -> Option<Vec<u8>> {
if data.get(..4)? != b"icns" {
return None;
}
let mut best: Option<&[u8]> = None;
let mut at = 8usize;
while let Some(head) = data.get(at..at.checked_add(8)?) {
let len = u32::from_be_bytes(head.get(4..8)?.try_into().ok()?) as usize;
if len < 8 {
break;
}
let body = data.get(at + 8..at.checked_add(len)?)?;
if body.starts_with(&PNG_MAGIC) && body.len() <= MAX_ICON_BYTES && best.is_none_or(|b| body.len() > b.len()) {
best = Some(body);
}
at = at.checked_add(len)?;
}
best.map(<[u8]>::to_vec)
}
fn u16_at(b: &[u8], at: usize) -> Option<u16> {
Some(u16::from_le_bytes(b.get(at..at.checked_add(2)?)?.try_into().ok()?))
}
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
Some(u32::from_le_bytes(b.get(at..at.checked_add(4)?)?.try_into().ok()?))
}
/// A Windows program, read in pieces (the files are large).
struct Pe<R> {
file: R,
sections: Vec<(u32, u32, u32)>,
/// File offset of the resource section's start, and its RVA.
res: (u64, u32),
}
impl<R: Read + Seek> Pe<R> {
fn bytes(&mut self, at: u64, len: usize) -> Option<Vec<u8>> {
if len > MAX_ICON_BYTES {
return None;
}
self.file.seek(SeekFrom::Start(at)).ok()?;
let mut out = vec![0; len];
self.file.read_exact(&mut out).ok()?;
Some(out)
}
fn open(mut file: R) -> Option<Pe<R>> {
file.seek(SeekFrom::Start(0)).ok()?;
let mut head = vec![0; 64];
file.read_exact(&mut head).ok()?;
if head.get(..2)? != b"MZ" {
return None;
}
let pe_at = u64::from(u32_at(&head, 0x3C)?);
let mut pe = Pe { file, sections: Vec::new(), res: (0, 0) };
let coff = pe.bytes(pe_at, 24)?;
if coff.get(..4)? != b"PE\0\0" {
return None;
}
let count = usize::from(u16_at(&coff, 6)?).min(96);
let optional_len = usize::from(u16_at(&coff, 20)?);
let optional = pe.bytes(pe_at + 24, optional_len.min(512))?;
let dirs_at = match u16_at(&optional, 0)? {
0x10B => 96,
0x20B => 112,
_ => return None,
};
// Data directory 2: the resource table.
let (rva, size) = (u32_at(&optional, dirs_at + 16)?, u32_at(&optional, dirs_at + 20)?);
if rva == 0 || size == 0 {
return None;
}
let table = pe.bytes(pe_at + 24 + optional_len as u64, count * 40)?;
for i in 0..count {
let s = table.get(i * 40..i * 40 + 40)?;
// (virtual address, virtual size, file offset)
pe.sections.push((u32_at(s, 12)?, u32_at(s, 8)?.max(u32_at(s, 16)?), u32_at(s, 20)?));
}
pe.res = (u64::from(pe.rva_to_offset(rva)?), rva);
Some(pe)
}
fn rva_to_offset(&self, rva: u32) -> Option<u32> {
self.sections.iter().find(|(va, size, _)| rva >= *va && rva - *va < *size).and_then(|(va, _, raw)| raw.checked_add(rva - va))
}
/// The entries of a resource directory at `offset` into the resource section: (id, target),
/// where the target's high bit marks a subdirectory.
fn directory(&mut self, offset: u32) -> Option<Vec<(u32, u32)>> {
let at = self.res.0.checked_add(u64::from(offset))?;
let head = self.bytes(at, 16)?;
let n = (usize::from(u16_at(&head, 12)?) + usize::from(u16_at(&head, 14)?)).min(MAX_RES_ENTRIES);
let list = self.bytes(at + 16, n * 8)?;
(0..n).map(|i| Some((u32_at(&list, i * 8)?, u32_at(&list, i * 8 + 4)?))).collect()
}
/// The first resource of `kind` (3 icon, 14 icon group): `id` picks one, `None` the first.
fn resource(&mut self, kind: u32, id: Option<u32>) -> Option<Vec<u8>> {
let types = self.directory(0)?;
let (_, kind_dir) = types.into_iter().find(|(t, target)| *t == kind && target & 0x8000_0000 != 0)?;
let named = self.directory(kind_dir & 0x7FFF_FFFF)?;
let (_, name_dir) = named.into_iter().find(|(n, _)| id.is_none_or(|id| *n == id))?;
let target = if name_dir & 0x8000_0000 != 0 {
// One level of languages; the first will do.
self.directory(name_dir & 0x7FFF_FFFF)?.first()?.1
} else {
name_dir
};
if target & 0x8000_0000 != 0 {
return None;
}
let entry = self.bytes(self.res.0.checked_add(u64::from(target))?, 16)?;
let (rva, size) = (u32_at(&entry, 0)?, u32_at(&entry, 4)? as usize);
self.bytes(u64::from(self.rva_to_offset(rva)?), size)
}
/// The program's first icon group as a one-image `.ico` file.
fn icon(&mut self) -> Option<Vec<u8>> {
let group = self.resource(14, None)?;
let count = usize::from(u16_at(&group, 4)?).min(64);
// The largest image (a width of 0 means 256).
let mut best: Option<(u32, usize)> = None;
for i in 0..count {
let e = group.get(6 + i * 14..6 + i * 14 + 14)?;
let width = if *e.first()? == 0 { 256 } else { u32::from(*e.first()?) };
if best.is_none_or(|(w, _)| width > w) {
best = Some((width, i));
}
}
let e = group.get(6 + best?.1 * 14..)?.get(..14)?;
let data = self.resource(3, Some(u32::from(u16_at(e, 12)?)))?;
let mut ico = vec![0, 0, 1, 0, 1, 0];
ico.extend_from_slice(e.get(..8)?);
ico.extend_from_slice(&(data.len() as u32).to_le_bytes());
ico.extend_from_slice(&22u32.to_le_bytes());
ico.extend_from_slice(&data);
Some(ico)
}
}
/// The main icon of a Windows program as `.ico` bytes.
pub fn exe_icon(path: &Path) -> Option<Vec<u8>> {
Pe::open(std::fs::File::open(path).ok()?)?.icon()
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
fn png_like(extra: usize) -> Vec<u8> {
let mut v = PNG_MAGIC.to_vec();
v.extend(std::iter::repeat_n(7u8, extra));
v
}
#[test]
fn icns_gives_the_largest_png() {
let (small, big) = (png_like(4), png_like(40));
let mut file = b"icns".to_vec();
file.extend_from_slice(&0u32.to_be_bytes());
for (kind, body) in [(b"is32", vec![1u8; 10]), (b"ic07", small), (b"ic08", big.clone())] {
file.extend_from_slice(kind);
file.extend_from_slice(&(body.len() as u32 + 8).to_be_bytes());
file.extend_from_slice(&body);
}
assert_eq!(icns_png(&file), Some(big));
assert_eq!(icns_png(b"nope"), None);
// A length that runs past the file ends the walk instead of panicking.
let mut bad = b"icns\0\0\0\0ic07".to_vec();
bad.extend_from_slice(&u32::MAX.to_be_bytes());
assert_eq!(icns_png(&bad), None);
}
/// A tiny PE file: one `.rsrc` section holding an icon group of one 16×16 image.
fn synthetic_pe(image: &[u8]) -> Vec<u8> {
let le32 = |v: u32| v.to_le_bytes().to_vec();
let le16 = |v: u16| v.to_le_bytes().to_vec();
let (pe_at, opt_len, raw) = (0x40usize, 0xF0usize, 0x200u32);
let rva = 0x1000u32;
// Resource section content, built back to front with absolute section offsets.
let group_data: Vec<u8> = [le16(0), le16(1), le16(1), vec![16, 16, 0, 0], le16(1), le16(32), le32(image.len() as u32), le16(5)].concat();
let dir = |entries: &[(u32, u32)]| -> Vec<u8> {
let mut d = [le32(0), le32(0), le16(0), le16(0), le16(0), le16(entries.len() as u16)].concat();
for (id, target) in entries {
d.extend(le32(*id));
d.extend(le32(*target));
}
d
};
// Layout: 0 root | 32 RT_ICON dir | 56 RT_GROUP_ICON dir | 80 icon 5 langs | 104 group 1 langs
// | 128 data entries (icon, group) | 160 icon bytes | then group bytes.
let icon_at = 160u32;
let group_at = icon_at + image.len() as u32;
let mut rsrc = dir(&[(3, 0x8000_0000 | 32), (14, 0x8000_0000 | 56)]);
rsrc.extend(dir(&[(5, 0x8000_0000 | 80)]));
rsrc.extend(dir(&[(1, 0x8000_0000 | 104)]));
rsrc.extend(dir(&[(0x409, 128)]));
rsrc.extend(dir(&[(0x409, 144)]));
assert_eq!(rsrc.len(), 128);
for (at, len) in [(icon_at, image.len()), (group_at, group_data.len())] {
rsrc.extend([le32(rva + at), le32(len as u32), le32(0), le32(0)].concat());
}
rsrc.extend_from_slice(image);
rsrc.extend_from_slice(&group_data);
let mut f = vec![0u8; 0x40];
f[..2].copy_from_slice(b"MZ");
f[0x3C..0x40].copy_from_slice(&(pe_at as u32).to_le_bytes());
f.extend(b"PE\0\0");
f.extend([le16(0x8664), le16(1), le32(0), le32(0), le32(0), le16(opt_len as u16), le16(0)].concat());
let mut opt = vec![0u8; opt_len];
opt[..2].copy_from_slice(&0x20Bu16.to_le_bytes());
opt[112 + 16..112 + 20].copy_from_slice(&rva.to_le_bytes());
opt[112 + 20..112 + 24].copy_from_slice(&(rsrc.len() as u32).to_le_bytes());
f.extend(opt);
let mut section = b".rsrc\0\0\0".to_vec();
section.extend([le32(rsrc.len() as u32), le32(rva), le32(rsrc.len() as u32), le32(raw), vec![0; 16]].concat());
f.extend(section);
f.resize(raw as usize, 0);
f.extend(rsrc);
f
}
#[test]
fn reads_the_icon_group_of_a_program() {
let image = vec![9u8; 40];
let ico = Pe::open(Cursor::new(synthetic_pe(&image))).and_then(|mut p| p.icon()).expect("icon");
assert_eq!(&ico[..6], &[0, 0, 1, 0, 1, 0]);
assert_eq!(&ico[6..8], &[16, 16]);
assert_eq!(u32::from_le_bytes([ico[14], ico[15], ico[16], ico[17]]), 40);
assert_eq!(&ico[22..], &image[..]);
}
#[test]
fn hostile_programs_give_nothing() {
let good = synthetic_pe(&[1; 8]);
assert!(Pe::open(Cursor::new(Vec::new())).is_none());
assert!(Pe::open(Cursor::new(b"MZ".to_vec())).is_none());
// Every truncation, and every single corrupted byte, must end without a panic.
for cut in 0..good.len() {
let _ = Pe::open(Cursor::new(good[..cut].to_vec())).and_then(|mut p| p.icon());
}
for at in 0..good.len() {
let mut bad = good.clone();
bad[at] = bad[at].wrapping_add(0x80);
let _ = Pe::open(Cursor::new(bad)).and_then(|mut p| p.icon());
}
}
}
+576
View File
@@ -0,0 +1,576 @@
//! Slicers a mesh can be sent to: find the installed ones on Linux, macOS and Windows, start one
//! with a file, and read its application icon (never shipped: it comes from the installed
//! application).
//!
//! Detection reads desktop entries and Flatpak exports (Linux), `.app` bundles (macOS) and the
//! program folders (Windows), plus `PATH` and the usual AppImage folders. Nothing here runs a
//! program except [`Slicer::open`].
use std::path::{Path, PathBuf};
use crate::slicer_icon;
/// Most directory entries looked at in one folder.
const MAX_ENTRIES: usize = 5_000;
/// Largest desktop entry read.
const MAX_DESKTOP_BYTES: u64 = 64 * 1024;
/// A slicer the user can send a mesh to.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Slicer {
/// `prusa`, `bambu`, … for the known ones; `custom` for a program the user picked.
pub id: String,
pub name: String,
/// The program and its leading arguments; the file to open is appended.
pub command: Vec<String>,
pub icon: Option<IconSource>,
}
/// Where an application's icon is read from.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum IconSource {
/// A PNG file (Linux icon themes).
Image(PathBuf),
/// A macOS `.icns` file.
Icns(PathBuf),
/// A Windows program with icon resources.
Exe(PathBuf),
}
impl IconSource {
/// The icon as PNG or ICO bytes, ready for an image decoder; `None` when it can't be read.
pub fn load(&self) -> Option<Vec<u8>> {
match self {
IconSource::Image(p) => slicer_icon::read_capped(p, 4 << 20),
IconSource::Icns(p) => slicer_icon::icns_png(&slicer_icon::read_capped(p, 32 << 20)?),
IconSource::Exe(p) => slicer_icon::exe_icon(p),
}
}
pub fn path(&self) -> &Path {
match self {
IconSource::Image(p) | IconSource::Icns(p) | IconSource::Exe(p) => p,
}
}
}
struct Known {
id: &'static str,
name: &'static str,
/// Names the application goes by, letters and digits only, lower case; an entry matches when
/// its name starts with one of them.
keys: &'static [&'static str],
/// Program file names.
exes: &'static [&'static str],
}
const KNOWN: &[Known] = &[
Known { id: "prusa", name: "PrusaSlicer", keys: &["prusaslicer"], exes: &["prusa-slicer", "PrusaSlicer", "prusa-slicer.exe"] },
Known { id: "bambu", name: "Bambu Studio", keys: &["bambustudio"], exes: &["bambu-studio", "BambuStudio", "bambu-studio.exe"] },
Known { id: "orca", name: "OrcaSlicer", keys: &["orcaslicer"], exes: &["orca-slicer", "OrcaSlicer", "orca-slicer.exe"] },
Known {
id: "cura",
name: "UltiMaker Cura",
keys: &["ultimakercura", "curaultimaker", "cura"],
exes: &["cura", "UltiMaker-Cura", "UltiMaker-Cura.exe", "Cura.exe"],
},
Known { id: "superslicer", name: "SuperSlicer", keys: &["superslicer"], exes: &["superslicer", "SuperSlicer", "superslicer.exe"] },
Known { id: "creality", name: "Creality Print", keys: &["crealityprint"], exes: &["CrealityPrint", "creality-print", "CrealityPrint.exe"] },
Known { id: "elegoo", name: "ElegooSlicer", keys: &["elegooslicer"], exes: &["elegoo-slicer", "ElegooSlicer", "ElegooSlicer.exe"] },
Known { id: "anycubic", name: "AnycubicSlicerNext", keys: &["anycubicslicer"], exes: &["AnycubicSlicerNext", "AnycubicSlicerNext.exe"] },
Known { id: "ideamaker", name: "ideaMaker", keys: &["ideamaker"], exes: &["ideamaker", "ideaMaker.exe"] },
Known { id: "lychee", name: "Lychee Slicer", keys: &["lycheeslicer"], exes: &["lychee-slicer", "Lychee Slicer.exe"] },
Known { id: "slic3r", name: "Slic3r", keys: &["slic3r"], exes: &["slic3r", "Slic3r", "slic3r.exe"] },
];
/// Lower-case letters and digits only: how names are compared.
fn normalize(s: &str) -> String {
s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect()
}
/// The known slicer an application name (or file name) belongs to.
fn known_for(name: &str) -> Option<&'static Known> {
let n = normalize(name);
// Companion tools (G-code viewers, command-line builds) are not slicers to send a mesh to.
if n.contains("viewer") || n.contains("console") || n.contains("gcode") || n.contains("uninstall") {
return None;
}
KNOWN.iter().find(|k| k.keys.iter().any(|key| n.starts_with(key)))
}
/// Folders and variables detection looks at, so it can be tested without the real machine.
#[derive(Clone, Debug, Default)]
pub struct Env {
/// The user's home folder.
pub home: Option<PathBuf>,
/// `PATH`.
pub path_dirs: Vec<PathBuf>,
/// XDG data folders (each holds `applications/` and `icons/`).
pub data_dirs: Vec<PathBuf>,
/// Folders holding AppImages, loose programs and per-slicer folders (Linux).
pub program_dirs: Vec<PathBuf>,
/// Folders holding `.app` bundles (macOS).
pub app_dirs: Vec<PathBuf>,
/// Folders holding one folder per installed program (Windows).
pub install_dirs: Vec<PathBuf>,
}
impl Env {
/// The running machine.
pub fn current() -> Env {
let var = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from);
let home = var("HOME").or_else(|| var("USERPROFILE"));
let mut env = Env {
home: home.clone(),
path_dirs: std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect()).unwrap_or_default(),
..Env::default()
};
if cfg!(target_os = "macos") {
env.app_dirs.push("/Applications".into());
env.app_dirs.extend(home.iter().map(|h| h.join("Applications")));
} else if cfg!(target_os = "windows") {
for k in ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] {
env.install_dirs.extend(var(k));
}
if let Some(local) = var("LOCALAPPDATA") {
env.install_dirs.push(local.join("Programs"));
env.install_dirs.push(local);
}
} else {
let data_home = var("XDG_DATA_HOME").or_else(|| home.as_ref().map(|h| h.join(".local/share")));
env.data_dirs.extend(data_home.clone());
env.data_dirs.extend(home.iter().map(|h| h.join(".local/share/flatpak/exports/share")));
env.data_dirs.push("/var/lib/flatpak/exports/share".into());
env.data_dirs.push("/var/lib/snapd/desktop".into());
match std::env::var_os("XDG_DATA_DIRS").filter(|v| !v.is_empty()) {
Some(dirs) => env.data_dirs.extend(std::env::split_paths(&dirs)),
None => env.data_dirs.extend(["/usr/local/share".into(), "/usr/share".into()]),
}
for dir in ["Applications", "AppImages", "Apps", ".local/bin", "bin"] {
env.program_dirs.extend(home.iter().map(|h| h.join(dir)));
}
env.program_dirs.extend(["/opt", "/usr/local/bin"].map(PathBuf::from));
}
env
}
}
/// The slicers installed on this machine, known ones in a fixed order. Empty in the browser.
pub fn detect() -> Vec<Slicer> {
if cfg!(target_arch = "wasm32") { Vec::new() } else { detect_in(&Env::current()) }
}
/// [`detect`] over the folders in `env`.
pub fn detect_in(env: &Env) -> Vec<Slicer> {
let mut found: Vec<Slicer> = Vec::new();
let mut add = |s: Slicer| {
if !found.iter().any(|f| f.id == s.id) {
found.push(s);
}
};
desktop_entries(env, &mut add);
path_programs(env, &mut add);
program_folders(env, &mut add);
mac_apps(env, &mut add);
windows_programs(env, &mut add);
let order = |s: &Slicer| KNOWN.iter().position(|k| k.id == s.id).unwrap_or(usize::MAX);
found.sort_by_key(order);
found
}
fn entries(dir: &Path) -> Vec<PathBuf> {
std::fs::read_dir(dir).map(|rd| rd.filter_map(|e| e.ok().map(|e| e.path())).take(MAX_ENTRIES).collect()).unwrap_or_default()
}
fn file_name(p: &Path) -> String {
p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
}
fn is_executable(p: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(p).map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0).unwrap_or(false)
}
#[cfg(not(unix))]
{
p.is_file()
}
}
fn s(p: &Path) -> String {
p.to_string_lossy().into_owned()
}
fn known_slicer(k: &Known, command: Vec<String>, icon: Option<IconSource>) -> Slicer {
Slicer { id: k.id.into(), name: k.name.into(), command, icon }
}
// --- Linux ---
/// A desktop entry's `[Desktop Entry]` keys.
fn parse_desktop(text: &str) -> std::collections::HashMap<String, String> {
let mut out = std::collections::HashMap::new();
let mut inside = false;
for line in text.lines().take(2_000) {
let line = line.trim();
if line.starts_with('[') {
inside = line == "[Desktop Entry]";
} else if inside && let Some((k, v)) = line.split_once('=') {
// Localised keys (`Name[de]`) never replace the plain one.
if !k.contains('[') {
out.entry(k.trim().to_string()).or_insert_with(|| v.trim().to_string());
}
}
}
out
}
/// An `Exec=` line as a command: quotes honoured, field codes (`%F`, …) dropped.
pub fn parse_exec(exec: &str) -> Vec<String> {
let mut args: Vec<String> = Vec::new();
let mut cur = String::new();
let mut quoted = false;
let mut started = false;
let mut chars = exec.chars();
while let Some(c) = chars.next() {
match c {
'"' => {
quoted = !quoted;
started = true;
}
'\\' if quoted => {
if let Some(n) = chars.next() {
cur.push(n);
}
}
c if c.is_whitespace() && !quoted => {
if started {
args.push(std::mem::take(&mut cur));
started = false;
}
}
c => {
cur.push(c);
started = true;
}
}
}
if started {
args.push(cur);
}
args.retain(|a| !(a.len() == 2 && a.starts_with('%')));
args
}
fn desktop_entries(env: &Env, add: &mut impl FnMut(Slicer)) {
for data in &env.data_dirs {
for file in entries(&data.join("applications")) {
if file.extension().is_none_or(|e| e != "desktop") || std::fs::metadata(&file).map(|m| m.len() > MAX_DESKTOP_BYTES).unwrap_or(true) {
continue;
}
let Ok(text) = std::fs::read_to_string(&file) else { continue };
let entry = parse_desktop(&text);
let Some(known) = entry.get("Name").and_then(|n| known_for(n)) else { continue };
if entry.get("Type").is_some_and(|t| t != "Application") {
continue;
}
let command = entry.get("Exec").map(|e| parse_exec(e)).unwrap_or_default();
let Some(program) = command.first() else { continue };
// A stale entry points at a program that is gone.
if Path::new(program).is_absolute() && !Path::new(program).is_file() {
continue;
}
let icon = entry.get("Icon").and_then(|i| theme_icon(env, i));
add(known_slicer(known, command, icon));
}
}
}
/// An icon by theme name (or absolute PNG path) from the icon themes.
fn theme_icon(env: &Env, name: &str) -> Option<IconSource> {
const SIZES: [u32; 7] = [256, 128, 192, 64, 48, 512, 32];
let direct = Path::new(name);
if direct.is_absolute() {
return (direct.extension().is_some_and(|e| e.eq_ignore_ascii_case("png")) && direct.is_file())
.then(|| IconSource::Image(direct.to_path_buf()));
}
if name.is_empty() || name.contains('/') || name.contains("..") {
return None;
}
for data in &env.data_dirs {
for size in SIZES {
let p = data.join(format!("icons/hicolor/{size}x{size}/apps/{name}.png"));
if p.is_file() {
return Some(IconSource::Image(p));
}
}
let p = data.join(format!("pixmaps/{name}.png"));
if p.is_file() {
return Some(IconSource::Image(p));
}
}
None
}
fn path_programs(env: &Env, add: &mut impl FnMut(Slicer)) {
for k in KNOWN {
for dir in &env.path_dirs {
for exe in k.exes {
let p = dir.join(exe);
if is_executable(&p) {
let icon = k.exes.iter().chain([&k.id]).find_map(|n| theme_icon(env, n.trim_end_matches(".exe")));
add(known_slicer(k, vec![s(&p)], icon));
}
}
}
}
}
/// AppImages and per-slicer folders in the usual places.
fn program_folders(env: &Env, add: &mut impl FnMut(Slicer)) {
for dir in &env.program_dirs {
for p in entries(dir) {
let name = file_name(&p);
let Some(known) = known_for(&name) else { continue };
if p.is_dir() {
let inside = known.exes.iter().flat_map(|e| [p.join(e), p.join("bin").join(e)]).find(|c| is_executable(c));
if let Some(exe) = inside {
let icon = theme_icon(env, known.id);
add(known_slicer(known, vec![s(&exe)], icon));
}
} else if name.to_ascii_lowercase().ends_with(".appimage") && is_executable(&p) {
add(known_slicer(known, vec![s(&p)], None));
}
}
}
}
// --- macOS ---
fn mac_apps(env: &Env, add: &mut impl FnMut(Slicer)) {
for dir in &env.app_dirs {
for app in entries(dir) {
let name = file_name(&app);
let Some(stem) = name.strip_suffix(".app") else { continue };
let Some(known) = known_for(stem) else { continue };
add(known_slicer(known, vec!["open".into(), "-a".into(), s(&app)], mac_icon(&app)));
}
}
}
/// The icon of a `.app` bundle: the `.icns` named like the app, else the first one.
fn mac_icon(app: &Path) -> Option<IconSource> {
let icons: Vec<PathBuf> = entries(&app.join("Contents/Resources")).into_iter().filter(|p| p.extension().is_some_and(|e| e == "icns")).collect();
let stem = normalize(&file_name(app).replace(".app", ""));
let pick = icons.iter().find(|p| p.file_stem().is_some_and(|n| normalize(&n.to_string_lossy()) == stem)).or_else(|| icons.iter().min());
pick.cloned().map(IconSource::Icns)
}
// --- Windows ---
fn windows_programs(env: &Env, add: &mut impl FnMut(Slicer)) {
for root in &env.install_dirs {
for dir in entries(root).into_iter().filter(|p| p.is_dir()) {
let Some(known) = known_for(&file_name(&dir)) else { continue };
let exe = known.exes.iter().filter(|e| e.ends_with(".exe")).flat_map(|e| [dir.join(e), dir.join("bin").join(e)]).find(|c| c.is_file());
if let Some(exe) = exe {
add(known_slicer(known, vec![s(&exe)], Some(IconSource::Exe(exe.clone()))));
}
}
}
}
// --- custom programs and launching ---
/// `name` with its first letter in capitals.
fn capitalised(name: &str) -> String {
let mut chars = name.chars();
chars.next().map(|c| c.to_uppercase().chain(chars).collect()).unwrap_or_default()
}
/// A program the user picked: an application bundle, a program file or an AppImage.
pub fn custom(path: &str) -> Option<Slicer> {
let p = Path::new(path);
let name = file_name(p);
let lower = name.to_ascii_lowercase();
let mac_app = lower.ends_with(".app") && p.is_dir();
if !(mac_app || (p.is_file() && is_executable(p))) {
return None;
}
let stem = [".app", ".exe", ".appimage"]
.iter()
.find(|ext| lower.ends_with(**ext))
.and_then(|ext| name.get(..name.len() - ext.len()))
.map_or_else(|| name.clone(), str::to_string);
let name = known_for(&stem).map_or_else(|| capitalised(&stem), |k| k.name.to_string());
let (command, icon) = if mac_app {
(vec!["open".into(), "-a".into(), path.to_string()], mac_icon(p))
} else if lower.ends_with(".exe") {
(vec![path.to_string()], Some(IconSource::Exe(p.to_path_buf())))
} else {
let stem = p.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
(vec![path.to_string()], theme_icon(&Env::current(), &stem))
};
Some(Slicer { id: "custom".into(), name, command, icon })
}
/// A slicer from a command parameter: the id or name of an installed one, else a program path.
pub fn resolve(spec: &str) -> Option<Slicer> {
let wanted = normalize(spec);
if !wanted.is_empty()
&& let Some(found) = detect().into_iter().find(|d| normalize(&d.id) == wanted || normalize(&d.name) == wanted)
{
return Some(found);
}
custom(spec)
}
/// `args` with the file added: at the end, or inside the `@@ … @@` pair of a Flatpak command, which
/// is how Flatpak lets the application see a file (`@@u` would hand it over as a URI).
#[cfg(not(target_arch = "wasm32"))]
fn with_file(args: &[String], path: &str) -> Vec<String> {
let mut out: Vec<String> = args.iter().map(|a| if a == "@@u" { "@@".to_string() } else { a.clone() }).collect();
let marks: Vec<usize> = out.iter().enumerate().filter(|(_, a)| *a == "@@").map(|(i, _)| i).collect();
match marks.as_slice() {
[.., _, last] => out.insert(*last, path.to_string()),
_ => out.push(path.to_string()),
}
out
}
impl Slicer {
/// Start the slicer with `path` loaded; does not wait for it to exit.
pub fn open(&self, path: &str) -> std::result::Result<(), String> {
#[cfg(target_arch = "wasm32")]
{
let _ = path;
Err(format!("{} can't be started from the browser", self.name))
}
#[cfg(not(target_arch = "wasm32"))]
{
use std::process::{Command, Stdio};
let (program, args) = self.command.split_first().ok_or_else(|| format!("{} has no program to start", self.name))?;
Command::new(program)
.args(with_file(args, path))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map(drop)
.map_err(|e| format!("couldn't start {} ({e}); the file is saved at {path}", self.name))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("solidcraft-slicers-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn names_match_known_slicers_but_not_their_companions() {
assert_eq!(known_for("PrusaSlicer").map(|k| k.id), Some("prusa"));
assert_eq!(known_for("Bambu Studio").map(|k| k.id), Some("bambu"));
assert_eq!(known_for("UltiMaker Cura 5.9").map(|k| k.id), Some("cura"));
assert!(known_for("PrusaSlicer G-code Viewer").is_none());
assert!(known_for("Firefox").is_none());
}
#[test]
fn exec_lines_become_commands() {
assert_eq!(parse_exec("prusa-slicer %F"), ["prusa-slicer"]);
assert_eq!(parse_exec(r#""/opt/My Apps/bambu" --x %U"#), ["/opt/My Apps/bambu", "--x"]);
assert_eq!(
parse_exec("flatpak run --branch=stable com.prusa3d.PrusaSlicer @@u %U @@"),
["flatpak", "run", "--branch=stable", "com.prusa3d.PrusaSlicer", "@@u", "@@"]
);
assert!(parse_exec("").is_empty());
}
#[test]
fn the_file_goes_where_the_command_expects_it() {
let v = |s: &str| s.split(' ').map(str::to_string).collect::<Vec<_>>();
assert_eq!(with_file(&v("run --file-forwarding app --x @@u @@"), "/t/a.3mf"), v("run --file-forwarding app --x @@ /t/a.3mf @@"));
assert_eq!(with_file(&v("-a /Applications/X.app"), "/t/a.stl"), v("-a /Applications/X.app /t/a.stl"));
assert_eq!(with_file(&[], "/t/a.stl"), v("/t/a.stl"));
}
#[test]
fn finds_linux_desktop_entries_and_their_icons() {
let root = scratch("linux");
let data = root.join("share");
std::fs::create_dir_all(data.join("applications")).unwrap();
std::fs::create_dir_all(data.join("icons/hicolor/128x128/apps")).unwrap();
std::fs::write(data.join("icons/hicolor/128x128/apps/PrusaSlicer.png"), b"png").unwrap();
std::fs::write(
data.join("applications/a.desktop"),
"[Desktop Entry]\nType=Application\nName=PrusaSlicer\nName[de]=Prusa\nExec=flatpak run com.prusa3d.PrusaSlicer %U\nIcon=PrusaSlicer\n",
)
.unwrap();
std::fs::write(data.join("applications/b.desktop"), "[Desktop Entry]\nName=Firefox\nExec=firefox\n").unwrap();
std::fs::write(data.join("applications/c.desktop"), "[Desktop Entry]\nName=PrusaSlicer G-code Viewer\nExec=prusa-slicer --gcodeviewer\n")
.unwrap();
let found = detect_in(&Env { data_dirs: vec![data.clone()], ..Env::default() });
assert_eq!(found.len(), 1);
assert_eq!(found[0].id, "prusa");
assert_eq!(found[0].command, ["flatpak", "run", "com.prusa3d.PrusaSlicer"]);
assert_eq!(found[0].icon, Some(IconSource::Image(data.join("icons/hicolor/128x128/apps/PrusaSlicer.png"))));
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn finds_mac_bundles_and_windows_folders() {
let root = scratch("mac-win");
let apps = root.join("Applications");
std::fs::create_dir_all(apps.join("BambuStudio.app/Contents/Resources")).unwrap();
std::fs::write(apps.join("BambuStudio.app/Contents/Resources/BambuStudio.icns"), b"x").unwrap();
std::fs::create_dir_all(apps.join("Safari.app")).unwrap();
let prog = root.join("Program Files");
std::fs::create_dir_all(prog.join("Prusa3D")).unwrap();
std::fs::create_dir_all(prog.join("OrcaSlicer")).unwrap();
std::fs::write(prog.join("OrcaSlicer/orca-slicer.exe"), b"x").unwrap();
let found = detect_in(&Env { app_dirs: vec![apps.clone()], install_dirs: vec![prog.clone()], ..Env::default() });
let ids: Vec<&str> = found.iter().map(|f| f.id.as_str()).collect();
assert_eq!(ids, ["bambu", "orca"]);
assert_eq!(found[0].command, ["open", "-a", apps.join("BambuStudio.app").to_str().unwrap()]);
assert!(matches!(found[0].icon, Some(IconSource::Icns(_))));
assert_eq!(found[1].icon, Some(IconSource::Exe(prog.join("OrcaSlicer/orca-slicer.exe"))));
std::fs::remove_dir_all(root).unwrap();
}
#[cfg(unix)]
#[test]
fn finds_programs_on_the_path_and_appimages() {
use std::os::unix::fs::PermissionsExt;
let root = scratch("path");
let bin = root.join("bin");
let apps = root.join("Applications");
std::fs::create_dir_all(&bin).unwrap();
std::fs::create_dir_all(&apps).unwrap();
let exe = bin.join("orca-slicer");
let image = apps.join("PrusaSlicer-2.8.AppImage");
for f in [&exe, &image] {
std::fs::write(f, b"#!/bin/sh\n").unwrap();
std::fs::set_permissions(f, std::fs::Permissions::from_mode(0o755)).unwrap();
}
std::fs::write(bin.join("prusa-slicer"), b"not executable").unwrap();
let found = detect_in(&Env { path_dirs: vec![bin.clone()], program_dirs: vec![apps.clone()], ..Env::default() });
let ids: Vec<&str> = found.iter().map(|f| f.id.as_str()).collect();
assert_eq!(ids, ["prusa", "orca"]);
assert_eq!(found[0].command, [image.to_str().unwrap()]);
// A picked program becomes a custom entry named after it; a missing path is refused.
let c = custom(exe.to_str().unwrap()).unwrap();
assert_eq!((c.id.as_str(), c.name.as_str()), ("custom", "OrcaSlicer"));
assert_eq!(capitalised("mycura"), "Mycura");
assert!(custom(root.join("nope").to_str().unwrap()).is_none());
assert!(custom(root.to_str().unwrap()).is_none());
std::fs::remove_dir_all(root).unwrap();
}
}
+36
View File
@@ -127,3 +127,39 @@ fn stl_export_is_closed_where_faces_meet() {
}
}
}
#[test]
fn save_mesh_rejects_unknown_format_and_slicer_without_writing() {
let mut s = imported_box();
let path = std::env::temp_dir().join(format!("solvecraft-save-mesh-{}.stl", std::process::id()));
let _ = std::fs::remove_file(&path);
for params in [json!({"path":path,"format":"nope"}), json!({"path":path,"slicer":"no such slicer anywhere"})] {
assert!(s.execute("file.save_mesh", &params).is_err());
assert!(!path.exists());
}
}
#[test]
fn save_mesh_writes_stl_and_3mf() {
let mut s = imported_box();
let dir = std::env::temp_dir();
for (ext, format) in [("stl", None), ("3mf", None), ("dat", Some("3mf"))] {
let path = dir.join(format!("solvecraft-save-mesh-{}.{ext}", std::process::id()));
let mut params = json!({"path": path});
if let Some(f) = format {
params["format"] = json!(f);
}
s.execute("file.save_mesh", &params).unwrap();
let bytes = std::fs::read(&path).unwrap();
// A 3MF file is a zip archive; a binary STL starts with its 80-byte header.
assert_eq!(bytes.starts_with(b"PK"), ext == "3mf" || format == Some("3mf"), "{ext}");
std::fs::remove_file(path).unwrap();
}
}
#[test]
fn installed_slicers_are_listed() {
let mut s = imported_box();
let r = s.execute("file.slicers", &json!({})).unwrap();
assert!(r["slicers"].is_array());
}