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

+1 -1
View File
@@ -130,7 +130,7 @@ rebuild with no errors, replayed here at 2× speed.
IGES 5.3 (manifold solid B-reps with names and colours), 3MF, STL (binary/ASCII) and OBJ, and
assemblies with joints as URDF for robot simulators (docs/urdf.md);
3MF, STL and OBJ import as mesh bodies (they render, measure, move and export; solid features
need B-rep bodies). Convert Mesh reconstructs supported closed constant-section parts
need B-rep bodies). The `mesh.convert` command reconstructs supported closed constant-section parts
as parametric solids with planar and circular cylindrical faces;
STEP import (AP203/AP214/AP242 solids, assemblies, units, names, colours) and IGES import
(manifold solid B-reps, or trimmed and bounded surfaces sewn into solids) as a base feature
+7
View File
@@ -98,6 +98,7 @@ impl eframe::App for App {
fn services(cc: &eframe::CreationContext<'_>) -> Services {
let parent = rfd::AsyncFileDialog::new().set_parent(cc);
let open_parent = parent.clone();
let program_parent = parent.clone();
Services {
pick_open: Some(Box::new(move || {
let future = open_parent
@@ -131,6 +132,12 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
.pick_file();
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
})),
pick_program: Some(Box::new(move || {
let dialog = program_parent.clone().set_title("Choose your slicer");
let dialog = if cfg!(target_os = "windows") { dialog.add_filter("Programs", &["exe"]) } else { dialog };
let future = dialog.pick_file();
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
})),
pick_save: Some(Box::new(move |name: &str, exts: &[&str]| {
let dialog = parent.clone().set_file_name(name);
let dialog = if exts == [io::DESIGN_EXT, "json"] {
+19
View File
@@ -312,6 +312,24 @@
"ui.revolve": "Drehen",
"ui.right": "Rechts",
"ui.save": "Speichern",
"ui.heading_save_mesh": "ALS NETZ SPEICHERN",
"ui.format": "Format",
"ui.mesh_format_stl": "STL",
"ui.mesh_format_3mf": "3MF",
"ui.mesh_format_3mf_tagged": "3MF mit Flächenmarken",
"ui.send_to_slicer": "An Slicer senden",
"ui.slicer": "Slicer",
"ui.custom_program": "Benutzerdefiniert…",
"ui.no_slicer_found": "Kein Slicer gefunden",
"ui.send": "Senden",
"ui.custom_program_tip": "Ein Slicer-Programm zur Liste hinzufügen",
"ui.slicer_delete_tip": "{program}\nMit Entf aus der Liste entfernen",
"ui.save_mesh_pick_bodies": "Wähle die Körper zum Speichern aus",
"ui.save_mesh_no_slicer": "Kein Slicer gefunden: „Benutzerdefiniert…“ wählen und das Slicer-Programm angeben, oder „An Slicer senden“ ausschalten",
"ui.not_a_program": "{path} ist kein startbares Programm",
"ui.sent_to_slicer": "An {slicer} gesendet",
"ui.saved_to": "Gespeichert: {path}",
"ui.save_mesh_temp_failed": "{folder} konnte nicht angelegt werden: {error}",
"ui.save_as": "Speichern unter",
"ui.save_as_ellipsis": "Speichern unter...",
"ui.scale": "Skalieren",
@@ -527,6 +545,7 @@
"cmd.file_insert_mesh": "Netz einfügen",
"cmd.file_insert_step": "STEP einfügen",
"cmd.file_save_mesh": "Als Netz speichern",
"cmd.file_slicers": "Installierte Slicer",
"cmd.inspect_accessibility": "Zugänglichkeitsanalyse",
"cmd.inspect_center_of_mass": "Massenschwerpunkt",
"cmd.inspect_curvature_comb": "Krümmungskamm-Analyse",
+19
View File
@@ -104,6 +104,7 @@
"cmd.file_insert_mesh": "Insert Mesh",
"cmd.file_insert_step": "Insert STEP",
"cmd.file_save_mesh": "Save As Mesh",
"cmd.file_slicers": "Installed Slicers",
"cmd.inspect_accessibility": "Accessibility Analysis",
"cmd.inspect_center_of_mass": "Center of Mass",
"cmd.inspect_curvature_comb": "Curvature Comb Analysis",
@@ -614,6 +615,24 @@
"ui.revolve": "Revolve",
"ui.right": "Right",
"ui.save": "Save",
"ui.heading_save_mesh": "SAVE AS MESH",
"ui.format": "Format",
"ui.mesh_format_stl": "STL",
"ui.mesh_format_3mf": "3MF",
"ui.mesh_format_3mf_tagged": "3MF with face tags",
"ui.send_to_slicer": "Send to slicer",
"ui.slicer": "Slicer",
"ui.custom_program": "Custom…",
"ui.no_slicer_found": "No slicer found",
"ui.send": "Send",
"ui.custom_program_tip": "Choose a slicer program to add to the list",
"ui.slicer_delete_tip": "{program}\nPress Delete to remove it from the list",
"ui.save_mesh_pick_bodies": "Select the bodies to save",
"ui.save_mesh_no_slicer": "No slicer found: choose Custom… and pick your slicer program, or turn off Send to slicer",
"ui.not_a_program": "{path} is not a program that can be started",
"ui.sent_to_slicer": "Sent to {slicer}",
"ui.saved_to": "Saved {path}",
"ui.save_mesh_temp_failed": "Couldn't make {folder}: {error}",
"ui.save_as": "Save As",
"ui.save_as_ellipsis": "Save As...",
"ui.scale": "Scale",
+19
View File
@@ -104,6 +104,7 @@
"cmd.file_insert_mesh": "メッシュを挿入",
"cmd.file_insert_step": "STEP を挿入",
"cmd.file_save_mesh": "メッシュとして保存",
"cmd.file_slicers": "インストール済みのスライサー",
"cmd.inspect_accessibility": "アクセシビリティ解析",
"cmd.inspect_center_of_mass": "重心",
"cmd.inspect_curvature_comb": "曲率コーム解析",
@@ -614,6 +615,24 @@
"ui.revolve": "回転",
"ui.right": "右側面",
"ui.save": "保存",
"ui.heading_save_mesh": "メッシュとして保存",
"ui.format": "形式",
"ui.mesh_format_stl": "STL",
"ui.mesh_format_3mf": "3MF",
"ui.mesh_format_3mf_tagged": "面タグ付き 3MF",
"ui.send_to_slicer": "スライサーに送る",
"ui.slicer": "スライサー",
"ui.custom_program": "カスタム…",
"ui.no_slicer_found": "スライサーが見つかりません",
"ui.send": "送信",
"ui.custom_program_tip": "リストに追加するスライサーのプログラムを選択",
"ui.slicer_delete_tip": "{program}\nDelete キーでリストから削除",
"ui.save_mesh_pick_bodies": "保存するボディを選択してください",
"ui.save_mesh_no_slicer": "スライサーが見つかりません。「カスタム…」でスライサーのプログラムを選ぶか、「スライサーに送る」をオフにしてください",
"ui.not_a_program": "{path} は起動できるプログラムではありません",
"ui.sent_to_slicer": "{slicer} に送信しました",
"ui.saved_to": "保存しました: {path}",
"ui.save_mesh_temp_failed": "{folder} を作成できません: {error}",
"ui.save_as": "名前を付けて保存",
"ui.save_as_ellipsis": "名前を付けて保存…",
"ui.scale": "尺度",
+19
View File
@@ -312,6 +312,24 @@
"ui.revolve": "회전",
"ui.right": "우측면",
"ui.save": "저장",
"ui.heading_save_mesh": "메쉬로 저장",
"ui.format": "형식",
"ui.mesh_format_stl": "STL",
"ui.mesh_format_3mf": "3MF",
"ui.mesh_format_3mf_tagged": "면 태그가 있는 3MF",
"ui.send_to_slicer": "슬라이서로 보내기",
"ui.slicer": "슬라이서",
"ui.custom_program": "사용자 지정…",
"ui.no_slicer_found": "슬라이서를 찾을 수 없음",
"ui.send": "보내기",
"ui.custom_program_tip": "목록에 추가할 슬라이서 프로그램 선택",
"ui.slicer_delete_tip": "{program}\nDelete 키로 목록에서 제거",
"ui.save_mesh_pick_bodies": "저장할 바디를 선택하세요",
"ui.save_mesh_no_slicer": "슬라이서를 찾을 수 없습니다. '사용자 지정…'에서 슬라이서 프로그램을 선택하거나 '슬라이서로 보내기'를 끄세요",
"ui.not_a_program": "{path}은(는) 실행할 수 있는 프로그램이 아닙니다",
"ui.sent_to_slicer": "{slicer}(으)로 보냈습니다",
"ui.saved_to": "저장됨: {path}",
"ui.save_mesh_temp_failed": "{folder}을(를) 만들 수 없습니다: {error}",
"ui.save_as": "다른 이름으로 저장",
"ui.save_as_ellipsis": "다른 이름으로 저장...",
"ui.scale": "축척",
@@ -527,6 +545,7 @@
"cmd.file_insert_mesh": "메쉬 삽입",
"cmd.file_insert_step": "STEP 삽입",
"cmd.file_save_mesh": "메쉬로 저장",
"cmd.file_slicers": "설치된 슬라이서",
"cmd.inspect_accessibility": "접근성 분석",
"cmd.inspect_center_of_mass": "무게중심",
"cmd.inspect_curvature_comb": "곡률빗 분석",
+19
View File
@@ -93,6 +93,7 @@
"cmd.file_insert_mesh": "Вставить меш",
"cmd.file_insert_step": "Вставить STEP",
"cmd.file_save_mesh": "Сохранить как меш",
"cmd.file_slicers": "Установленные слайсеры",
"cmd.inspect_accessibility": "Анализ доступности",
"cmd.inspect_center_of_mass": "Центр масс",
"cmd.inspect_curvature_comb": "Анализ кривизны (гребёнка)",
@@ -585,6 +586,24 @@
"ui.revolve": "Вращение",
"ui.right": "Справа",
"ui.save": "Сохранить",
"ui.heading_save_mesh": "СОХРАНИТЬ КАК МЕШ",
"ui.format": "Формат",
"ui.mesh_format_stl": "STL",
"ui.mesh_format_3mf": "3MF",
"ui.mesh_format_3mf_tagged": "3MF с метками граней",
"ui.send_to_slicer": "Отправить в слайсер",
"ui.slicer": "Слайсер",
"ui.custom_program": "Другая…",
"ui.no_slicer_found": "Слайсер не найден",
"ui.send": "Отправить",
"ui.custom_program_tip": "Выберите программу-слайсер для добавления в список",
"ui.slicer_delete_tip": "{program}\nНажмите Delete, чтобы убрать из списка",
"ui.save_mesh_pick_bodies": "Выберите тела для сохранения",
"ui.save_mesh_no_slicer": "Слайсер не найден: выберите «Другая…» и укажите программу или отключите «Отправить в слайсер»",
"ui.not_a_program": "{path} — не программа, которую можно запустить",
"ui.sent_to_slicer": "Отправлено в {slicer}",
"ui.saved_to": "Сохранено: {path}",
"ui.save_mesh_temp_failed": "Не удалось создать {folder}: {error}",
"ui.save_as": "Сохранить как",
"ui.save_as_ellipsis": "Сохранить как...",
"ui.scale": "Масштаб",
+19
View File
@@ -104,6 +104,7 @@
"cmd.file_insert_mesh": "插入网格",
"cmd.file_insert_step": "插入 STEP",
"cmd.file_save_mesh": "另存为网格",
"cmd.file_slicers": "已安装的切片软件",
"cmd.inspect_accessibility": "可达性分析",
"cmd.inspect_center_of_mass": "质心",
"cmd.inspect_curvature_comb": "曲率梳分析",
@@ -612,6 +613,24 @@
"ui.revolve": "旋转",
"ui.right": "右视",
"ui.save": "保存",
"ui.heading_save_mesh": "另存为网格",
"ui.format": "格式",
"ui.mesh_format_stl": "STL",
"ui.mesh_format_3mf": "3MF",
"ui.mesh_format_3mf_tagged": "带面标签的 3MF",
"ui.send_to_slicer": "发送到切片软件",
"ui.slicer": "切片软件",
"ui.custom_program": "自定义…",
"ui.no_slicer_found": "未找到切片软件",
"ui.send": "发送",
"ui.custom_program_tip": "选择要添加到列表的切片软件程序",
"ui.slicer_delete_tip": "{program}\n按 Delete 键从列表中移除",
"ui.save_mesh_pick_bodies": "请选择要保存的实体",
"ui.save_mesh_no_slicer": "未找到切片软件:请选择“自定义…”并指定切片软件程序,或关闭“发送到切片软件”",
"ui.not_a_program": "{path} 不是可以启动的程序",
"ui.sent_to_slicer": "已发送到 {slicer}",
"ui.saved_to": "已保存:{path}",
"ui.save_mesh_temp_failed": "无法创建 {folder}:{error}",
"ui.save_as": "另存为",
"ui.save_as_ellipsis": "另存为...",
"ui.scale": "缩放",
+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());
}
+1 -1
View File
@@ -17,7 +17,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
web-time = { workspace = true }
image = { workspace = true, features = ["jpeg"] }
image = { workspace = true, features = ["jpeg", "ico"] }
ttf-parser = { workspace = true }
semver = { workspace = true }
# Updates: the downloaded package's SHA-256 (against the release's SHA256SUMS.txt), its optional
@@ -164,6 +164,7 @@ const ALLOWED_LITERALS: &[(&str, usize, &str)] = &[
("canvas.rs", 3, "white rings around manipulator handles"),
("context_menu.rs", 1, "the radial menu's shadow"),
("dialogs_appearance.rs", 1, "a design appearance: the colour is the fact"),
("dialogs_save_mesh.rs", 1, "an installed slicer's own icon, drawn untinted"),
("dialogs_library.rs", 1, "a library appearance's swatch: the colour is the fact"),
("dialogs_assembly.rs", 1, "the interference fill, shaded from its token"),
("dialogs_motion.rs", 1, "a white outline"),
+5 -2
View File
@@ -418,6 +418,7 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
v.push(Item::sep());
v.push(act("ui.appearance", "Appearance", "appearance").key("A"));
v.push(act("ui.properties", "Properties", "measure").with(json!({ "bodies": [face] })));
v.push(act("ui.saveMesh", "Save As Mesh", "export").with(json!({ "bodies": [face] })));
v.push(Item::sep());
v.push(act("ui.delete", "Delete", "delete").key("Del"));
v.push(act(if hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").key("V").with(json!({ "bodies": [face] })));
@@ -437,6 +438,7 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
v.push(act(if hidden { "ui.show" } else { "ui.hide" }, "Show/Hide", "eye").key("V").with(json!({ "bodies": [body] })));
v.push(Item::sep());
v.push(act("ui.findBrowser", "Find in Browser", "").with(json!({ "body": body })));
v.push(act("ui.saveMesh", "Save As Mesh", "export").with(json!({ "bodies": [body] })));
v.push(Item::sep());
v.push(act("ui.tangentChain", "Select Tangent Chain", ""));
v.push(cmd(app, "inspect.measure", "Measure"));
@@ -1068,8 +1070,9 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
app.start("material.assign");
}
"ui.saveMesh" => {
let name = bodies.first().cloned().unwrap_or_else(|| app.session.doc.name.clone());
app.pick_save_command(&format!("{name}.stl"), &["stl"], "file.save_mesh", json!({"bodies": bodies}));
let items: Vec<Value> = bodies.iter().map(|b| json!({"type": "body", "name": b})).collect();
let _ = app.run("select.set", json!({ "items": items }));
app.start("file.save_mesh");
}
"ui.exportDxf" => {
if let Some(id) = id_of(&p, "sketch") {
+19 -3
View File
@@ -253,6 +253,7 @@ pub enum Kind {
Sheet(crate::dialogs_sheet::Sm),
/// Surface tools and Split Face (`dialogs_surface`).
Surface(crate::dialogs_surface::Surf),
SaveMesh(crate::dialogs_save_mesh::SaveMesh),
/// Plastic features (`dialogs_plastic`).
Plastic(crate::dialogs_plastic::Pl),
/// Appearance (`dialogs_appearance`).
@@ -602,6 +603,7 @@ impl Dialog {
let (kind, inputs) = crate::dialogs_assembly::start(app, id)
.or_else(|| crate::dialogs_sheet::start(app, id))
.or_else(|| crate::dialogs_surface::start(app, id))
.or_else(|| crate::dialogs_save_mesh::start(app, id))
.or_else(|| crate::dialogs_plastic::start(app, id))
.or_else(|| crate::dialogs_appearance::start(app, id))
.or_else(|| crate::dialogs_motion::start(app, id))
@@ -1371,6 +1373,7 @@ fn title(k: &Kind) -> &'static str {
Kind::Assembly(k) => k.title(),
Kind::Sheet(k) => k.title(),
Kind::Surface(k) => k.title(),
Kind::SaveMesh(_) => "SAVE AS MESH",
Kind::Plastic(k) => k.title(),
Kind::Appearance(_) => "APPEARANCE",
Kind::Motion(k) => k.title(),
@@ -1675,7 +1678,7 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui, s: &Session) {
/// Width limits of a docked dialog: shrink-wrapped to its content within these.
use crate::frame::{DIALOG_MAX_W, DIALOG_MIN_W, DIALOG_WIDE_MAX_W};
/// Width of value fields and choice boxes in a dialog.
const FIELD_W: f32 = 124.0;
pub(crate) const FIELD_W: f32 = 124.0;
/// Find the Boundary Fill cells again when the tool bodies changed (keeping the choices of
/// cells that are still there).
@@ -1914,7 +1917,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let horizontal_frame = frame.inner_margin.leftf() + frame.inner_margin.rightf() + 2.0 * frame.stroke.width;
// OK is offered once the inputs make a feature.
let has_ok = !matches!(d.kind, Kind::Sketch | Kind::Params { .. } | Kind::Measure { .. });
let valid = !has_ok || !d.previews() || (built.unwrap_or_else(|| apply_commands(app, &d).is_ok()) && app.preview.error.is_none());
let valid = match &d.kind {
Kind::SaveMesh(_) => crate::dialogs_save_mesh::ready(&d.inputs),
_ => !has_ok || !d.previews() || (built.unwrap_or_else(|| apply_commands(app, &d).is_ok()) && app.preview.error.is_none()),
};
let width = if wide { crate::frame::Width::Wide } else { crate::frame::Width::Normal };
let wkey = egui::Id::new(("sc_dialog_w", wide));
let response = crate::frame::docked_on(
@@ -2670,6 +2676,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
Kind::Sheet(k) => enter |= crate::dialogs_sheet::rows(app, ui, k, &mut d.inputs),
Kind::Surface(k) => enter |= crate::dialogs_surface::rows(ui, k),
Kind::SketchModify(k) => enter |= crate::sketch_modify_ui::rows(ui, k),
Kind::SaveMesh(k) => enter |= crate::dialogs_save_mesh::rows(app, ui, k),
Kind::Plastic(k) => enter |= crate::dialogs_plastic::rows(app, ui, k, &d.inputs),
Kind::Appearance(k) => enter |= crate::dialogs_appearance::rows(app, ui, k, &mut d.inputs),
Kind::Motion(k) => enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs),
@@ -2729,7 +2736,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
egui::Spinner::new().size(14.0).paint_at(ui, sr);
sresp.on_hover_text(tr("Updating the preview"));
}
let raw_label = if matches!(d.kind, Kind::ConfirmDelete { .. }) { "Delete" } else { "OK" };
let raw_label = match &d.kind {
Kind::ConfirmDelete { .. } => "Delete",
Kind::SaveMesh(k) => crate::dialogs_save_mesh::button(k),
_ => "OK",
};
let label = tr(raw_label);
let raw_close = if matches!(d.kind, Kind::Params { .. } | Kind::Measure { .. }) { "Close" } else { "Cancel" };
let close = tr(raw_close);
@@ -3171,6 +3182,10 @@ fn sels(d: &Dialog, i: usize) -> &[Sel] {
}
pub(crate) fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> {
// Saving asks for a file name first; `Err("")` is the user backing out of that.
if let Kind::SaveMesh(k) = &d.kind {
return crate::dialogs_save_mesh::run(app, k, &d.inputs);
}
let commands = apply_commands(app, d)?;
if let Some((_, marker)) = d.editing {
// Record the actual pre-edit timeline state in the command's undo snapshot.
@@ -3862,6 +3877,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
Kind::Sheet(k) => return crate::dialogs_sheet::commands(app, k, &d.inputs, &d.extra),
Kind::Surface(k) => return crate::dialogs_surface::commands(app, k, &d.inputs, &d.extra),
Kind::SketchModify(k) => return crate::sketch_modify_ui::commands(app, k, &d.inputs, &d.extra),
Kind::SaveMesh(k) => return crate::dialogs_save_mesh::commands(k, &d.inputs),
Kind::Plastic(k) => return crate::dialogs_plastic::commands(app, k, &d.inputs, &d.extra),
Kind::Appearance(k) => return Ok(crate::dialogs_appearance::commands(k, &d.inputs)),
Kind::Motion(k) => return crate::dialogs_motion::commands(app, k, &d.inputs),
+347
View File
@@ -0,0 +1,347 @@
//! The Save as mesh dialog (`file.save_mesh`): which bodies, the mesh format, and optionally a
//! slicer to open the saved file in.
//!
//! Every place that saves a mesh opens this dialog: File › Save as mesh, the Mesh tab, and the
//! Save As Mesh item of the body menus. The slicer list holds the programs the user added (kept
//! between runs, newest first), then the slicers found on this machine, then "Custom…", which asks
//! for a program and adds it at the top; "Custom…" itself is never the selection. Pressing Delete
//! over a program the user added removes it.
use std::collections::HashMap;
use egui::{Color32, Sense, vec2};
use serde_json::{Value, json};
use solidcraft_engine::Sel;
use solidcraft_engine::slicers::{self, Slicer};
use crate::SolveApp;
use crate::dialogs::{Kind, combo, row_label};
use crate::selection::{BODIES, SelInput};
use crate::theme::Tokens;
/// The formats, as shown and as `file.save_mesh` names them.
/// (label, `format` parameter, file extension).
const FORMATS: [(&str, &str, &str); 3] = [("STL", "stl", "stl"), ("3MF", "3mf", "3mf"), ("3MF with face tags", "3mf-tagged", "3mf")];
/// Edge of a slicer's icon in the list.
const ICON: f32 = 16.0;
/// Most programs the user can keep in the list.
const MAX_CUSTOM: usize = 24;
/// The dialog's values.
#[derive(Clone, Debug, PartialEq)]
pub struct SaveMesh {
format: usize,
send: bool,
/// The chosen slicer: an installed one's id, or the path of one the user added.
slicer: String,
/// The slicer list has not been read for this opening yet.
stale: bool,
}
/// One line of the slicer list.
struct Entry {
key: String,
slicer: Slicer,
/// Added by the user (can be deleted).
user: bool,
}
/// The slicers known to the dialog, and their icons.
#[derive(Default)]
pub struct SlicerMenu {
detected: Vec<Slicer>,
custom: Vec<(String, Slicer)>,
icons: HashMap<String, Option<egui::TextureHandle>>,
/// A program chosen in the Custom… file dialog, waiting for the open dialog to take it.
picked: Option<String>,
}
impl SlicerMenu {
/// Look at the machine and at the saved programs again.
pub fn refresh(&mut self, custom_paths: &[String]) {
self.detected = slicers::detect();
self.set_custom(custom_paths);
}
fn set_custom(&mut self, paths: &[String]) {
// A program that has been uninstalled drops out of the list (it stays in the settings).
self.custom = paths.iter().filter_map(|p| slicers::custom(p).map(|s| (p.clone(), s))).collect();
}
fn entries(&self) -> Vec<Entry> {
let user = self.custom.iter().map(|(path, s)| Entry { key: path.clone(), slicer: s.clone(), user: true });
let found = self.detected.iter().map(|s| Entry { key: s.id.clone(), slicer: s.clone(), user: false });
user.chain(found).collect()
}
}
/// The dialog for `file.save_mesh`, with the choices of last time.
pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
if id != "file.save_mesh" {
return None;
}
let p = &app.preferences;
let format = FORMATS.iter().position(|f| f.1 == p.save_mesh_format).unwrap_or(0);
let kind = Kind::SaveMesh(SaveMesh { format, send: p.save_mesh_send, slicer: p.save_mesh_slicer.clone(), stale: true });
Some((kind, vec![SelInput::new("Bodies", BODIES, true)]))
}
/// A popup with the slicer list is open (Delete belongs to it, not to the design's selection).
pub fn popup_open(ctx: &egui::Context) -> bool {
let frame = ctx.cumulative_frame_nr();
let at = ctx.data(|d| d.get_temp::<u64>(egui::Id::new("sc_slicer_popup")));
at.is_some_and(|n| n + 1 >= frame)
}
/// The icon of an entry as a texture (`None`: no icon could be read).
fn icon(menu: &mut SlicerMenu, ctx: &egui::Context, e: &Entry) -> Option<egui::TextureId> {
let tex = menu.icons.entry(e.key.clone()).or_insert_with(|| {
let bytes = e.slicer.icon.as_ref()?.load()?;
let img = decode(&bytes)?;
Some(ctx.load_texture(format!("slicer-icon-{}", e.key), img, egui::TextureOptions::LINEAR))
});
tex.as_ref().map(egui::TextureHandle::id)
}
/// An icon file (PNG or ICO) as a small square image.
fn decode(bytes: &[u8]) -> Option<egui::ColorImage> {
const SIDE: u32 = 48;
let reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
let (w, h) = reader.into_dimensions().ok()?;
if w == 0 || h == 0 || w > 4096 || h > 4096 {
return None;
}
let img = image::load_from_memory(bytes).ok()?;
let small = image::imageops::resize(&img.to_rgba8(), SIDE, SIDE, image::imageops::FilterType::Lanczos3);
Some(egui::ColorImage::from_rgba_unmultiplied([SIDE as usize, SIDE as usize], small.as_raw()))
}
/// An icon, or the first letter of the name on a tile when there is none.
fn paint_icon(ui: &egui::Ui, rect: egui::Rect, tex: Option<egui::TextureId>, name: &str) {
match tex {
Some(id) => {
ui.painter().image(id, rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE);
}
None => {
let t = Tokens::get();
ui.painter().rect_filled(rect, 3.0, t.chip);
let letter = name.chars().next().map(|c| c.to_uppercase().to_string()).unwrap_or_default();
ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, letter, egui::FontId::proportional(11.0), t.text);
}
}
}
/// The rows after the Bodies input; true when Enter was pressed in a field.
pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut SaveMesh) -> bool {
if k.stale {
// Look at the machine once per opening, then settle on a slicer that exists.
k.stale = false;
app.slicer_menu.picked = None;
app.slicer_menu.refresh(&app.preferences.custom_slicers);
let entries = app.slicer_menu.entries();
if !entries.iter().any(|e| e.key == k.slicer) {
k.slicer = entries.first().map(|e| e.key.clone()).unwrap_or_default();
}
k.send &= !entries.is_empty();
}
row_label(ui, "Format");
combo(ui, "sm_format", &FORMATS.map(|f| f.0), &mut k.format);
ui.end_row();
// A browser has no installed programs to send a file to.
if app.services.pick_program.is_none() {
k.send = false;
return false;
}
row_label(ui, "Send to slicer");
ui.checkbox(&mut k.send, "");
ui.end_row();
row_label(ui, "Slicer");
if let Some(path) = app.slicer_menu.picked.take() {
match add_custom(app, &path) {
Ok(()) => k.slicer = path,
Err(e) => app.set_status(e, true),
}
}
let choosing = app.file_picker_busy();
ui.add_enabled_ui(k.send && !choosing, |ui| slicer_combo(app, ui, k));
ui.end_row();
false
}
fn slicer_combo(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut SaveMesh) {
let entries = app.slicer_menu.entries();
let ctx = ui.ctx().clone();
let current = entries.iter().find(|e| e.key == k.slicer);
let pad = "\u{2003}\u{2003}";
let text = current.map_or_else(|| crate::i18n::tr_label("No slicer found").to_string(), |e| format!("{pad}{}", e.slicer.name));
let mut pick_custom = false;
let mut delete: Option<String> = None;
let mut chosen: Option<String> = None;
let r = egui::ComboBox::from_id_salt("sm_slicer").selected_text(text).width(crate::dialogs::FIELD_W).show_ui(ui, |ui| {
let frame = ctx.cumulative_frame_nr();
ctx.data_mut(|d| d.insert_temp(egui::Id::new("sc_slicer_popup"), frame));
for e in &entries {
let tex = icon(&mut app.slicer_menu, &ctx, e);
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
let selected = e.key == k.slicer;
if resp.hovered() || selected {
let v = ui.visuals();
let fill = if resp.hovered() { v.widgets.hovered.bg_fill } else { v.selection.bg_fill.gamma_multiply(0.5) };
ui.painter().rect_filled(rect, 2.0, fill);
}
paint_icon(ui, egui::Rect::from_min_size(rect.left_center() + vec2(3.0, -ICON / 2.0), vec2(ICON, ICON)), tex, &e.slicer.name);
ui.painter().text(
rect.left_center() + vec2(ICON + 9.0, 0.0),
egui::Align2::LEFT_CENTER,
&e.slicer.name,
egui::FontId::proportional(13.0),
ui.visuals().text_color(),
);
crate::scenario::publish_handle(&format!("dialog:Slicer:{}", e.slicer.name), rect.center());
if e.user {
let hint = crate::i18n::format("ui.slicer_delete_tip", &[("program", e.slicer.command.first().map_or("", String::as_str))]);
resp.clone().on_hover_text(hint);
if resp.hovered() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete)) {
delete = Some(e.key.clone());
}
}
if resp.clicked() {
chosen = Some(e.key.clone());
ui.close();
}
}
if !entries.is_empty() {
ui.separator();
}
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(rect, 2.0, ui.visuals().widgets.hovered.bg_fill);
}
ui.painter().text(
rect.left_center() + vec2(ICON + 9.0, 0.0),
egui::Align2::LEFT_CENTER,
crate::i18n::tr_label("Custom…"),
egui::FontId::proportional(13.0),
Tokens::get().text,
);
crate::scenario::publish_handle("dialog:Slicer:Custom", rect.center());
if resp.on_hover_text(crate::i18n::tr("ui.custom_program_tip")).clicked() {
pick_custom = true;
ui.close();
}
});
if let Some(e) = current {
let tex = icon(&mut app.slicer_menu, &ctx, e);
let rect = r.response.rect;
let at = egui::Rect::from_min_size(rect.left_center() + vec2(ui.spacing().button_padding.x + 1.0, -ICON / 2.0), vec2(ICON, ICON));
paint_icon(ui, at, tex, &e.slicer.name);
}
if let Some(key) = chosen {
k.slicer = key;
}
if let Some(key) = delete {
app.preferences.custom_slicers.retain(|p| *p != key);
app.slicer_menu.set_custom(&app.preferences.custom_slicers);
if k.slicer == key {
k.slicer = app.slicer_menu.entries().first().map(|e| e.key.clone()).unwrap_or_default();
}
}
if pick_custom {
// Closing the file dialog without a program leaves the choice as it was.
app.pick_program(|app, path| app.slicer_menu.picked = path);
}
}
/// Put a program the user picked at the top of the list (and keep it).
fn add_custom(app: &mut SolveApp, path: &str) -> Result<(), String> {
if slicers::custom(path).is_none() {
return Err(crate::i18n::format("ui.not_a_program", &[("path", path)]));
}
let list = &mut app.preferences.custom_slicers;
list.retain(|p| p != path);
list.insert(0, path.to_string());
list.truncate(MAX_CUSTOM);
app.slicer_menu.set_custom(&app.preferences.custom_slicers);
Ok(())
}
fn bodies(inputs: &[SelInput]) -> Vec<String> {
inputs
.first()
.map(|i| i.items.iter().filter_map(|s| if let Sel::Body { name } = s { Some(name.clone()) } else { None }).collect())
.unwrap_or_default()
}
/// What the Save (or Send) button asks `file.save_mesh` to do, without the file name.
pub fn commands(k: &SaveMesh, inputs: &[SelInput]) -> Result<Vec<(String, Value)>, String> {
let format = FORMATS.get(k.format).map_or("stl", |f| f.1);
let mut p = json!({ "format": format });
let names = bodies(inputs);
if names.is_empty() {
return Err(crate::i18n::tr("ui.save_mesh_pick_bodies").into());
}
p["bodies"] = json!(names);
if k.send {
if k.slicer.is_empty() {
return Err(crate::i18n::tr("ui.save_mesh_no_slicer").into());
}
p["slicer"] = json!(k.slicer);
}
Ok(vec![("file.save_mesh".into(), p)])
}
/// Save and Send need at least one body.
pub fn ready(inputs: &[SelInput]) -> bool {
!bodies(inputs).is_empty()
}
/// The button's label: Save, or Send when the file goes to a slicer.
pub fn button(k: &SaveMesh) -> &'static str {
if k.send { "Send" } else { "Save" }
}
/// Save: ask for a file name and write the file. Send: write it to a temporary file and start the
/// slicer with it, with no file dialog.
pub fn run(app: &mut SolveApp, k: &SaveMesh, inputs: &[SelInput]) -> Result<(), String> {
let (_, params) = commands(k, inputs)?.into_iter().next().ok_or("nothing to save")?;
let (format, ext) = FORMATS.get(k.format).map_or(("stl", "stl"), |f| (f.1, f.2));
let name = bodies(inputs).first().cloned().unwrap_or_else(|| app.session.doc.name.clone());
let p = &mut app.preferences;
p.save_mesh_format = format.into();
p.save_mesh_send = k.send;
if !k.slicer.is_empty() {
p.save_mesh_slicer = k.slicer.clone();
}
if k.send {
let dir = std::env::temp_dir().join("solidcraft-slicer");
std::fs::create_dir_all(&dir)
.map_err(|e| crate::i18n::format("ui.save_mesh_temp_failed", &[("folder", &dir.display().to_string()), ("error", &e.to_string())]))?;
let file: String = name.chars().map(|c| if c.is_alphanumeric() || "-_. ".contains(c) { c } else { '_' }).collect();
save(app, params, &dir.join(format!("{file}.{ext}")).to_string_lossy());
} else {
// The file dialog answers later; the dialog closes meanwhile, as for every export.
app.pick_save(&format!("{name}.{ext}"), &[ext], move |app, path| {
if let Some(path) = path {
save(app, params, &path);
}
});
}
Ok(())
}
/// Run `file.save_mesh` into `path` and report what happened.
fn save(app: &mut SolveApp, mut params: Value, path: &str) {
// Runs from the file picker's answer, outside the frame's language scope.
let _language = crate::i18n::scope(&app.preferences.language);
params["path"] = json!(path);
let Ok(result) = app.run("file.save_mesh", params) else { return };
match result.get("slicer_error").and_then(Value::as_str) {
Some(e) => app.set_status(e.to_string(), true),
None => match result.get("slicer").and_then(Value::as_str) {
Some(s) => app.set_status(crate::i18n::format("ui.sent_to_slicer", &[("slicer", s)]), false),
None => app.set_status(crate::i18n::format("ui.saved_to", &[("path", path)]), false),
},
}
}
+1 -15
View File
@@ -45,9 +45,6 @@ pub enum Surf {
Stitch {
tolerance: String,
},
ConvertMesh {
tolerance: String,
},
Thicken {
thickness: String,
symmetric: bool,
@@ -79,7 +76,6 @@ impl Surf {
Surf::Offset { .. } => "OFFSET SURFACE",
Surf::Ruled { .. } => "RULED SURFACE",
Surf::Stitch { .. } => "STITCH",
Surf::ConvertMesh { .. } => "CONVERT MESH",
Surf::Thicken { .. } => "THICKEN",
Surf::Extend { .. } => "EXTEND",
Surf::Trim => "TRIM",
@@ -124,7 +120,6 @@ fn inputs(k: &Surf) -> Vec<SelInput> {
vec![SelInput::new("Edges", EDGES, true), SelInput::new("Reference face", FACES, false), SelInput::new("Direction", AXES | EDGES, false)]
}
Surf::Stitch { .. } => vec![SelInput::new("Surfaces", BODIES, true)],
Surf::ConvertMesh { .. } => vec![SelInput::new("Mesh body", BODIES, false)],
Surf::Thicken { .. } => vec![SelInput::new("Surfaces", BODIES, true)],
Surf::Extend { .. } => vec![SelInput::new("Edges", EDGES, true)],
Surf::Trim => vec![
@@ -150,7 +145,6 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
"surface.offset" => Surf::Offset { distance: "2 mm".into(), chain: false },
"surface.ruled" => Surf::Ruled { distance: "10 mm".into(), mode: 0, angle: "5 deg".into(), flip: false, round: false },
"surface.stitch" => Surf::Stitch { tolerance: String::new() },
"mesh.convert" => Surf::ConvertMesh { tolerance: "0.05 mm".into() },
"surface.thicken" => Surf::Thicken { thickness: "2 mm".into(), symmetric: false, operation: 0 },
"surface.extend" => Surf::Extend { distance: "10 mm".into() },
"surface.trim" => Surf::Trim,
@@ -166,7 +160,7 @@ pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
pub fn rows(ui: &mut egui::Ui, k: &mut Surf) -> bool {
let mut enter = false;
match k {
Surf::Stitch { tolerance } | Surf::ConvertMesh { tolerance } => {
Surf::Stitch { tolerance } => {
row_label(ui, "Tolerance");
let r = ui.add(egui::TextEdit::singleline(tolerance).hint_text("tight"));
crate::params_dialog::complete(ui, &r, tolerance);
@@ -469,11 +463,6 @@ pub fn commands(app: &SolveApp, k: &Surf, inputs: &[SelInput], extra: &Map<Strin
}
("surface.stitch", p)
}
Surf::ConvertMesh { tolerance } => {
need(0, "a closed mesh body")?;
let body = bodies(inputs, 0).into_iter().next().ok_or("select a mesh body")?;
("mesh.convert", json!({"body":body,"tolerance":tolerance}))
}
Surf::Thicken { thickness, symmetric, operation } => {
need(0, "the surfaces to thicken")?;
(
@@ -661,9 +650,6 @@ pub fn for_feature(app: &SolveApp, kind: &FeatureKind, frame: &Mat) -> Option<(K
Surf::Stitch { tolerance: if tolerance.trim() == "0" { String::new() } else { tolerance.clone() } },
vec![bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect()],
),
FeatureKind::MeshConvert { body, tolerance } => {
(Surf::ConvertMesh { tolerance: tolerance.clone() }, vec![vec![Sel::Body { name: body.clone() }]])
}
FeatureKind::Thicken { bodies, thickness, symmetric, operation, .. } => (
Surf::Thicken { thickness: thickness.clone(), symmetric: *symmetric, operation: op_index(operation) },
vec![bodies.iter().map(|n| Sel::Body { name: n.clone() }).collect()],
+9
View File
@@ -49,6 +49,15 @@ impl SolveApp {
self.begin_picker(future, Box::new(complete));
}
/// Ask for a program (a slicer). Without a host dialog, the answer is a cancel.
pub(crate) fn pick_program(&mut self, complete: impl FnOnce(&mut SolveApp, Option<String>) + 'static) {
if self.file_picker_busy() {
return;
}
let future = self.services.pick_program.as_ref().map(|pick| pick());
self.begin_picker(future, Box::new(complete));
}
fn begin_picker(&mut self, future: Option<FilePickerFuture>, complete: Completion) {
let Some(future) = future else {
complete(self, None);
+6
View File
@@ -29,6 +29,7 @@ pub mod dialogs_motion;
pub mod dialogs_move;
pub mod dialogs_parts;
pub mod dialogs_plastic;
pub mod dialogs_save_mesh;
pub mod dialogs_sheet;
pub mod dialogs_surface;
pub mod dim_view;
@@ -228,6 +229,8 @@ impl Default for UiState {
pub struct Services {
pub pick_open: Option<Box<dyn Fn() -> FilePickerFuture>>,
pub pick_save: Option<Box<dyn Fn(&str, &[&str]) -> FilePickerFuture>>,
/// Open a dialog asking for a program (a slicer); `None` when the host has no such dialog.
pub pick_program: Option<Box<dyn Fn() -> FilePickerFuture>>,
pub show_in_folder: Option<Box<dyn Fn(&str)>>,
/// Where the host reads the anti-aliasing preference at start (a small JSON file).
pub graphics_path: Option<String>,
@@ -283,6 +286,8 @@ pub struct SolveApp {
/// Preferences kept between runs, and their window.
pub preferences: prefs::Prefs,
pub prefs_window: prefs::PrefsWindow,
/// The slicers the Save as mesh dialog offers.
pub slicer_menu: dialogs_save_mesh::SlicerMenu,
pub preview: preview::PreviewState,
/// OK pressed on a feature that takes a while: its evaluation runs off the window thread.
pub applying: Option<dialogs::Applying>,
@@ -382,6 +387,7 @@ impl SolveApp {
sketch_choices: sketch_families::Choices::default(),
preferences: prefs::Prefs::default(),
prefs_window: prefs::PrefsWindow::default(),
slicer_menu: dialogs_save_mesh::SlicerMenu::default(),
preview: preview::PreviewState::default(),
applying: None,
apply_off_thread: None,
+10
View File
@@ -295,6 +295,12 @@ pub struct Prefs {
pub check_updates: bool,
/// When the last update check ran, and the versions skipped or put off (`update::Memo`).
pub update: crate::update::Memo,
/// Save as mesh: the last format (`stl` or `3mf`), whether it was sent to a slicer, which one
/// (an installed slicer's id or a program path), and the programs the user added (newest first).
pub save_mesh_format: String,
pub save_mesh_send: bool,
pub save_mesh_slicer: String,
pub custom_slicers: Vec<String>,
}
/// What a middle-button double-click in the view does.
@@ -359,6 +365,10 @@ impl Default for Prefs {
color_vision: crate::theme::ColorVision::Off,
check_updates: true,
update: crate::update::Memo::default(),
save_mesh_format: "stl".into(),
save_mesh_send: false,
save_mesh_slicer: String::new(),
custom_slicers: Vec::new(),
}
}
}
+3 -11
View File
@@ -403,22 +403,14 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
close = true;
}
ui.separator();
if item_if(ui, "Export STL…", "", shown) {
export(app, "stl");
if item_if(ui, "Save as mesh…", "", shown) {
app.start("file.save_mesh");
close = true;
}
if item_if(ui, "Export STEP…", "", shown) {
export(app, "step");
close = true;
}
if item_if(ui, "Export 3MF…", "", shown) {
export(app, "3mf");
close = true;
}
if item_if(ui, "Export 3MF with Face Tags…", "", shown) {
export_as(app, "3mf", Some("3mf-tagged"));
close = true;
}
if item_if(ui, "Export OBJ…", "", shown) {
export(app, "obj");
close = true;
@@ -1172,7 +1164,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
}
// A dimension value that is taking the keyboard: letters belong to it (a parameter name
// starting with L or D), even on the frame before its field holds the focus.
if ctx.egui_wants_keyboard_input() || crate::dim_view::editing() {
if ctx.egui_wants_keyboard_input() || crate::dim_view::editing() || crate::dialogs_save_mesh::popup_open(ctx) {
return;
}
let (keys, mods) = ctx.input(|i| {
-44
View File
@@ -1,44 +0,0 @@
use serde_json::json;
use solidcraft_engine::doc::{Document, FeatureKind, MeshData};
use solidcraft_engine::geom::Vec3;
use solidcraft_engine::{Sel, Session};
use solidcraft_ui_egui::scenario::Harness;
#[test]
fn convert_mesh_uses_preselection_and_reopens_its_timeline_dialog() {
let body = solidcraft_engine::kernel::box_solid(Vec3::ZERO, Vec3::new(10., 20., 30.)).unwrap();
let mesh = body.tessellate(0.01).unwrap();
let mut doc = Document::default();
doc.add_feature(
FeatureKind::MeshImport {
file: "box.stl".into(),
meshes: vec![MeshData {
name: "Mesh".into(),
positions: mesh.positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
triangles: mesh.triangles.iter().flatten().copied().collect(),
color: None,
}],
},
None,
)
.unwrap();
let mut h = Harness::new();
h.app.session = Session::new(doc);
h.app.session.selection = vec![Sel::Body { name: "Mesh".into() }];
h.step(&json!({"start":"mesh.convert"})).unwrap();
let dialog = h.app.dialog.as_ref().unwrap();
assert_eq!(dialog.inputs[0].items, vec![Sel::Body { name: "Mesh".into() }]);
h.step(&json!({"widget":"OK"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.status);
assert!(!h.app.session.model.state().body("Mesh").unwrap().body.is_mesh());
let feature = h.app.session.doc.features.last().unwrap().id;
let response = h.call("ui.editFeature", json!({"feature":feature}));
assert_eq!(response["ok"], true, "{response}");
let dialog = h.app.dialog.as_ref().unwrap();
assert!(
matches!(&dialog.kind,solidcraft_ui_egui::dialogs::Kind::Surface(solidcraft_ui_egui::dialogs_surface::Surf::ConvertMesh { tolerance }) if tolerance=="0.05 mm")
);
assert!(h.app.session.model.state().body("Mesh").unwrap().body.is_mesh());
h.step(&json!({"key":"Escape"})).unwrap();
assert!(!h.app.session.model.state().body("Mesh").unwrap().body.is_mesh());
}
@@ -101,11 +101,28 @@
]
},
{
"widget": "Export 3MF\u2026"
"widget": "Save as mesh\u2026"
},
{
"expect": {
"file": "Untitled.3mf"
"dialog": "SaveMesh"
}
},
{
"click": {
"world": [
15,
10,
10
]
}
},
{
"widget": "Save"
},
{
"expect": {
"file": "Body1.stl"
}
}
]
]
+8 -13
View File
@@ -16,21 +16,16 @@ Opening a STEP or IGES file creates a design with the file's bodies as a base fe
inserting one adds them to the current design. Later features build on them. Mesh bodies render,
measure, move and export, but solid features need B-rep bodies.
For closed mechanical meshes with a constant cross-section, use **Mesh → Modify →
Convert Mesh**. Select the mesh body and set the fitting tolerance. The conversion
rebuilds planar faces and circular through-walls, replacing the mesh with a solid
that accepts solid modeling operations. The imported triangles remain embedded
upstream: changing their scale, undoing the conversion or reopening the design
rebuilds the result through the timeline. Double-click the conversion to edit its
tolerance or source body.
**File → Save as mesh…** (also on the Mesh tab under Modify, and in the right-click menu of a
body) saves the selected bodies as STL, 3MF or 3MF with face tags (below). With **Send to slicer** ticked, it writes the file to
a temporary folder and opens it in the chosen slicer instead of asking for a file name. The slicer
list shows the slicers found on the computer with their icons; **Custom…** adds any other program
to the top of the list (kept between runs; hover an entry you added and press Delete to remove it).
The tolerance bounds circular reconstruction against the original chords. Coarse
polygons remain polygons when rounding would exceed it. Open/nonmanifold meshes,
tapers, stepped parts and organic scans are refused by this first reconstruction
method; it does not silently substitute a faceted solid. Conversion currently
accepts at most 50,000 triangles and a bounded number of boundary segments.
Closed mechanical meshes with a constant cross-section can be rebuilt as solids with the
`mesh.convert` command (command palette, scripts and the control channel).
**File → Export 3MF with Face Tags** (`file.export` with `format: "3mf-tagged"`) writes an
**File → Save as mesh…** with the format **3MF with face tags** (`file.export` or `file.save_mesh` with `format: "3mf-tagged"`) writes an
ordinary 3MF in which every triangle also names the B-rep face it lies on and every coordinate
is the tessellation's own double-precision value. Meshing and analysis tools can use the tags to
keep a model's faces (for loads, boundary conditions or feature edges); other 3MF readers ignore
+1 -1
View File
@@ -7,7 +7,7 @@ A tessellated body loses its faces: a plain mesh says where the surface is, not
each triangle came from. Downstream tools that mesh or analyse a part need that back. They apply
loads and boundary conditions to faces, keep face boundaries as feature edges, and group
results by face. SolidCraft therefore exports a 3MF in which every triangle carries the index of
the B-rep face it lies on (**File → Export 3MF with Face Tags**, or `file.export` with
the B-rep face it lies on (**File → Save as mesh…** (format **3MF with face tags**), or `file.export` with
`format: "3mf-tagged"`).
## Why 3MF