diff --git a/README.md b/README.md index 9b83162d..233e6406 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/solidcraft/src/main.rs b/apps/solidcraft/src/main.rs index b64fe928..a687dade 100644 --- a/apps/solidcraft/src/main.rs +++ b/apps/solidcraft/src/main.rs @@ -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"] { diff --git a/assets/locales/de.json b/assets/locales/de.json index 93569ee2..5e1c167b 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -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", diff --git a/assets/locales/en.json b/assets/locales/en.json index ef1f8c89..2dd5226e 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -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", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index c48544ec..3306922a 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -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": "尺度", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 4208a776..ca2ca85d 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -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": "곡률빗 분석", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index abbccf40..3587ac1c 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -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": "Масштаб", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 17b3ce8f..8dd6b774 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -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": "缩放", diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 1c4291dd..f873b27b 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -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 { @@ -450,10 +457,43 @@ fn export_urdf(s: &Session, path: &str, p: &Value) -> Result { ) } -fn save_stl(s: &mut Session, p: &Value) -> Result { - 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 { + 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 { + let list: Vec = 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 { diff --git a/crates/engine/src/cmd/mesh.rs b/crates/engine/src/cmd/mesh.rs index 71361b65..702270f7 100644 --- a/crates/engine/src/cmd/mesh.rs +++ b/crates/engine/src/cmd/mesh.rs @@ -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?"), ]; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 52f68c60..2d5a4faf 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/slicer_icon.rs b/crates/engine/src/slicer_icon.rs new file mode 100644 index 00000000..6b679845 --- /dev/null +++ b/crates/engine/src/slicer_icon.rs @@ -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> { + 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> { + 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 { + Some(u16::from_le_bytes(b.get(at..at.checked_add(2)?)?.try_into().ok()?)) +} + +fn u32_at(b: &[u8], at: usize) -> Option { + 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 { + file: R, + sections: Vec<(u32, u32, u32)>, + /// File offset of the resource section's start, and its RVA. + res: (u64, u32), +} + +impl Pe { + fn bytes(&mut self, at: u64, len: usize) -> Option> { + 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> { + 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 { + 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> { + 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) -> Option> { + 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> { + 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> { + 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 { + 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 { + 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 = [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 { + 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()); + } + } +} diff --git a/crates/engine/src/slicers.rs b/crates/engine/src/slicers.rs new file mode 100644 index 00000000..40b30507 --- /dev/null +++ b/crates/engine/src/slicers.rs @@ -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, + pub icon: Option, +} + +/// 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> { + 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, + /// `PATH`. + pub path_dirs: Vec, + /// XDG data folders (each holds `applications/` and `icons/`). + pub data_dirs: Vec, + /// Folders holding AppImages, loose programs and per-slicer folders (Linux). + pub program_dirs: Vec, + /// Folders holding `.app` bundles (macOS). + pub app_dirs: Vec, + /// Folders holding one folder per installed program (Windows). + pub install_dirs: Vec, +} + +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 { + 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 { + let mut found: Vec = 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 { + 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, icon: Option) -> 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 { + 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 { + let mut args: Vec = 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 { + 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 { + let icons: Vec = 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 { + 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 { + 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 { + let mut out: Vec = args.iter().map(|a| if a == "@@u" { "@@".to_string() } else { a.clone() }).collect(); + let marks: Vec = 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::>(); + 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(); + } +} diff --git a/crates/engine/src/tests/mesh_convert.rs b/crates/engine/src/tests/mesh_convert.rs index 93335270..881d4ef3 100644 --- a/crates/engine/src/tests/mesh_convert.rs +++ b/crates/engine/src/tests/mesh_convert.rs @@ -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", ¶ms).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", ¶ms).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()); +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index a83683fa..1fc0cf8c 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -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 diff --git a/crates/ui-egui/src/colour_tokens_tests.rs b/crates/ui-egui/src/colour_tokens_tests.rs index a2d892e4..57915bee 100644 --- a/crates/ui-egui/src/colour_tokens_tests.rs +++ b/crates/ui-egui/src/colour_tokens_tests.rs @@ -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"), diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index b64f49e0..00eece9a 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -418,6 +418,7 @@ fn viewport_items(app: &SolveApp) -> Vec { 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 { 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 = 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") { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c42d44ff..7b52aa41 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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, 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), diff --git a/crates/ui-egui/src/dialogs_save_mesh.rs b/crates/ui-egui/src/dialogs_save_mesh.rs new file mode 100644 index 00000000..ccaf2c39 --- /dev/null +++ b/crates/ui-egui/src/dialogs_save_mesh.rs @@ -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, + custom: Vec<(String, Slicer)>, + icons: HashMap>, + /// A program chosen in the Custom… file dialog, waiting for the open dialog to take it. + picked: Option, +} + +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 { + 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)> { + 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::(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 { + 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 { + 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, 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 = None; + let mut chosen: Option = 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 { + 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, 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), + }, + } +} diff --git a/crates/ui-egui/src/dialogs_surface.rs b/crates/ui-egui/src/dialogs_surface.rs index d82fa880..df9ae473 100644 --- a/crates/ui-egui/src/dialogs_surface.rs +++ b/crates/ui-egui/src/dialogs_surface.rs @@ -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 { 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)> { "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)> { 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 { - 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()], diff --git a/crates/ui-egui/src/file_picker.rs b/crates/ui-egui/src/file_picker.rs index f57579e5..f508ed58 100644 --- a/crates/ui-egui/src/file_picker.rs +++ b/crates/ui-egui/src/file_picker.rs @@ -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) + '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, complete: Completion) { let Some(future) = future else { complete(self, None); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 40679563..1be810e6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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 FilePickerFuture>>, pub pick_save: Option FilePickerFuture>>, + /// Open a dialog asking for a program (a slicer); `None` when the host has no such dialog. + pub pick_program: Option FilePickerFuture>>, pub show_in_folder: Option>, /// Where the host reads the anti-aliasing preference at start (a small JSON file). pub graphics_path: Option, @@ -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, @@ -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, diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 8b801961..33298cd6 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -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, } /// 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(), } } } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index b9c1a7e8..caa85a3a 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -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| { diff --git a/crates/ui-egui/tests/mesh_conversion.rs b/crates/ui-egui/tests/mesh_conversion.rs deleted file mode 100644 index 60afa90f..00000000 --- a/crates/ui-egui/tests/mesh_conversion.rs +++ /dev/null @@ -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()); -} diff --git a/crates/ui-egui/tests/scenarios/s14_appearance_export.json b/crates/ui-egui/tests/scenarios/s14_appearance_export.json index ffb3cf81..e97d86cf 100644 --- a/crates/ui-egui/tests/scenarios/s14_appearance_export.json +++ b/crates/ui-egui/tests/scenarios/s14_appearance_export.json @@ -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" } } -] \ No newline at end of file +] diff --git a/docs/book/src/guide/files.md b/docs/book/src/guide/files.md index c90d83d6..90384e1f 100644 --- a/docs/book/src/guide/files.md +++ b/docs/book/src/guide/files.md @@ -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 diff --git a/docs/formats/3mf-face-tags.md b/docs/formats/3mf-face-tags.md index aeb5486f..27581f2d 100644 --- a/docs/formats/3mf-face-tags.md +++ b/docs/formats/3mf-face-tags.md @@ -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