CMMS embedding: Export to CAM - build STL, save, navigate to Kiri:Moto

Wires Message::StlExport's existing STLOUT command to the CMMS when
embedded, instead of the native save-dialog flow (which was already
non-functional on the web build regardless - handle_path() has no real
filesystem to write to there, a pre-existing upstream gap, not something
this touches).

- Cargo.toml: add the Location web-sys feature (needed for
  window.location().set_href()).
- src/sys.rs: post_stl_export_to_host() - POSTs STL bytes as a new
  attachment (cad_export_stl action, not cad_save/cad_save_new - STL isn't
  in CAD_EDITABLE_EXT, it needs its own allowlist), then navigates the
  browser straight to the CMMS's Kiri:Moto CAM page for the attachment it
  just created. Splits cfg.job_id's "type:id" owner token apart to build
  cam-edit.php's entity_type=/entity_id= params.
- src/app/mod.rs + update/mod.rs: CmmsStlExportResult message/handler -
  only ever surfaces a failure (a success already navigated away).
- src/app/update/mod.rs: Message::StlExport gains a wasm32+embedded branch
  that skips the file-picker entirely and calls the above directly.
This commit is contained in:
AI Assistant 2026-08-23 17:41:02 +10:00
commit 3f8b69c71b
4 changed files with 120 additions and 0 deletions

View file

@ -82,6 +82,7 @@ js-sys = "0.3"
bincode = "1.3"
web-sys = { version = "=0.3.85", features = [
"Window",
"Location",
"Storage",
"StorageManager",
"Navigator",

View file

@ -1796,6 +1796,12 @@ pub enum Message {
/// to the host page instead of triggering a browser download.
#[cfg(target_arch = "wasm32")]
CmmsSaveResult(Result<(), String>),
/// CMMS embedding (not upstream): result of "Export to CAM" - POSTing a
/// freshly-built STL as a new attachment and navigating to Kiri:Moto.
/// A successful result already navigated away, so this only ever
/// surfaces a failure to the command line.
#[cfg(target_arch = "wasm32")]
CmmsStlExportResult(Result<(), String>),
SaveFile,
SaveAs,
// ── Custom Save-As dialog ─────────────────────────────────────────────

View file

@ -839,6 +839,20 @@ impl OpenCADStudio {
Task::none()
}
// A success already navigated the browser to the CAM page
// (post_stl_export_to_host does this itself once the save
// response comes back) - only a failure is ever actually seen
// here, the whole point of surfacing it in the command line
// rather than just logging to the console.
#[cfg(target_arch = "wasm32")]
Message::CmmsStlExportResult(result) => {
if let Err(error) = result {
self.command_line
.push_error(crate::tf!("Export to CAM failed: {error}").as_ref());
}
Task::none()
}
Message::ImagePick => {
Task::perform(crate::io::pick_image_file(), Message::ImagePickResult)
}
@ -985,6 +999,31 @@ impl OpenCADStudio {
.push_error(crate::t!("STLOUT: no 3D mesh data in this drawing.").as_ref());
return Task::none();
}
// CMMS embedding (not upstream), "Export to CAM": when
// launched from the CMMS, skip the (broken on web anyway -
// handle_path() has no real filesystem to write to there)
// native save-dialog flow entirely and instead build STL
// bytes, POST them as a new attachment, and navigate
// straight to the Kiri:Moto CAM page for it.
#[cfg(target_arch = "wasm32")]
if crate::sys::cmms_config().is_some() {
let meshes: Vec<crate::scene::model::mesh_model::MeshModel> = self.tabs[i]
.scene
.meshes
.values()
.filter_map(|s| s.lods.first().cloned())
.collect();
let filename = format!("{}.stl", self.tabs[i].tab_display_name());
return Task::perform(
async move {
let mesh_refs: Vec<_> = meshes.iter().collect();
let bytes = crate::io::stl::build_stl(&mesh_refs)
.ok_or_else(|| "no mesh data to export".to_string())?;
crate::sys::post_stl_export_to_host(&bytes, &filename).await
},
Message::CmmsStlExportResult,
);
}
Task::perform(
async {
crate::sys::file_dialog()

View file

@ -442,6 +442,80 @@ pub async fn post_bytes_to_host(bytes: &[u8], filename: &str) -> Result<(), Stri
Ok(())
}
/// CMMS embedding (not upstream): "Export to CAM" - POSTs STL bytes as a
/// brand-new attachment (mirrors post_bytes_to_host's create path but
/// against the cad_export_stl action, not cad_save/cad_save_new - STL
/// isn't in CAD_EDITABLE_EXT, it needs its own allowlist), then navigates
/// the browser straight to the Kiri:Moto CAM page for the file it just
/// created. `cfg.job_id` already carries the "type:id" owner token (see
/// public/vendor/cad-editor/edit.php's $ownerToken) - split it apart here
/// to build cam-edit.php's separate entity_type=/entity_id= params.
#[cfg(target_arch = "wasm32")]
pub async fn post_stl_export_to_host(bytes: &[u8], filename: &str) -> Result<(), String> {
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
let cfg = cmms_config().ok_or("not running inside the CMMS")?;
let form = web_sys::FormData::new().map_err(|_| "could not build form data")?;
form.append_with_str("action", "cad_export_stl")
.map_err(|_| "form append failed")?;
form.append_with_str("job_id", &cfg.job_id)
.map_err(|_| "form append failed")?;
form.append_with_str("original_name", filename)
.map_err(|_| "form append failed")?;
form.append_with_str("csrf_token", &cfg.csrf_token)
.map_err(|_| "form append failed")?;
let array = js_sys::Uint8Array::from(bytes);
let parts = js_sys::Array::new();
parts.push(&array.buffer());
let blob = web_sys::Blob::new_with_u8_array_sequence(&parts)
.map_err(|_| "could not build blob")?;
form.append_with_blob("file", &blob)
.map_err(|_| "form append failed")?;
let window = web_sys::window().ok_or("no window")?;
let opts = web_sys::RequestInit::new();
opts.set_method("POST");
opts.set_body(&form);
let request = web_sys::Request::new_with_str_and_init(&cfg.save_url, &opts)
.map_err(|_| "could not build request")?;
let response: web_sys::Response = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|_| "export request failed")?
.dyn_into()
.map_err(|_| "export response is invalid")?;
if !response.ok() {
return Err(format!("export failed: HTTP {}", response.status()));
}
let text = JsFuture::from(response.text().map_err(|_| "text() unavailable")?)
.await
.map_err(|_| "export response body read failed")?;
let body = text.as_string().ok_or("export response body is not a string")?;
let parsed: serde_json::Value =
serde_json::from_str(&body).map_err(|_| "export response is not valid JSON")?;
let new_id = parsed
.get("id")
.and_then(|v| v.as_i64())
.ok_or("export response missing id")?;
let (entity_type, entity_id) = cfg
.job_id
.split_once(':')
.ok_or("malformed owner token")?;
let url = format!(
"/cam-edit.php?entity_type={}&entity_id={}&attachment_id={new_id}",
percent_encode(entity_type),
percent_encode(entity_id)
);
window
.location()
.set_href(&url)
.map_err(|_| "navigation to the CAM page failed")?;
Ok(())
}
/// Short platform string for bug reports: OS + architecture on the desktop,
/// the browser user-agent on the web.
#[cfg(not(target_arch = "wasm32"))]