//! isokit in the browser. [`Studio`] holds one project in memory (a //! [`MemStore`]) and answers the same JSON as `isokit studio`'s `/api/*` //! routes, so the Studio GUI runs unchanged on top of it; the page persists //! the files it writes ([`Studio::take_written`]). Also exposes the MCP tools //! ([`Studio::mcp`]) for agents working inside the page. //! //! Strings in, JSON strings out: no serde-wasm glue, and the same calls work //! in native tests. use isokit_project::store::MemStore; use isokit_project::workspace::Workspace; use isokit_project::{api, check, mcp, page, Files}; use serde_json::{json, Map, Value}; use wasm_bindgen::prelude::*; #[cfg(target_arch = "wasm32")] #[wasm_bindgen] extern "C" { #[wasm_bindgen(js_namespace = console)] fn error(s: &str); } /// Panics go to the browser console instead of "unreachable executed". #[cfg(target_arch = "wasm32")] #[wasm_bindgen(start)] pub fn start() { std::panic::set_hook(Box::new(|info| error(&format!("isokit: {info}")))); } fn parse_files(files: &str) -> Result { let v: Map = serde_json::from_str(files).map_err(|e| format!("files: {e}"))?; Ok(v.into_iter() .filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_string()))) .collect()) } #[wasm_bindgen] pub struct Studio { ws: Workspace, } #[wasm_bindgen] impl Studio { /// `files`: JSON object, relative path -> text. `name` labels the /// project in messages ("browser: Demo", a folder name). #[wasm_bindgen(constructor)] pub fn new(name: &str, files: &str) -> Result { let store = MemStore::new(name, parse_files(files)?); let ws = Workspace::with_store(store).map_err(|e| e.to_string())?; Ok(Studio { ws }) } /// GET /api/meta pub fn meta(&mut self) -> String { self.ws.refresh(); api::meta(&self.ws).to_string() } /// GET /api/doc pub fn state(&mut self) -> String { self.ws.refresh(); api::state(&self.ws).to_string() } /// GET /api/version pub fn version(&mut self) -> String { self.ws.refresh(); self.ws.version.to_string() } /// POST /api/op: one operation or `{"ops": [...]}`. pub fn op(&mut self, body: &str) -> String { match serde_json::from_str::(body) { Ok(v) => api::op(&mut self.ws, &v).to_string(), Err(e) => json!({ "error": format!("bad operation: {e}") }).to_string(), } } /// POST /api/undo pub fn undo(&mut self) -> String { api::undo(&mut self.ws, false).to_string() } /// POST /api/redo pub fn redo(&mut self) -> String { api::undo(&mut self.ws, true).to_string() } /// GET /api/render. `cfg`: JSON object of parameter values, or "". pub fn render(&mut self, level: &str, rot: u8, cfg: &str, dev: bool) -> String { self.ws.refresh(); let cfg = serde_json::from_str::>(cfg).ok(); let level = Some(level).filter(|l| !l.is_empty()); api::render(&self.ws, level, rot % 4, cfg.as_ref(), dev).to_string() } /// GET /api/check pub fn check(&mut self) -> String { self.ws.refresh(); json!({ "findings": check::check(&self.ws.project) }).to_string() } /// The theme's CSS (built-in theme plus the project's layers). pub fn theme_css(&mut self) -> String { self.ws.refresh(); self.ws.project.theme.css() } /// The self-contained HTML page: `{"html"}` or `{"error"}`. pub fn page(&mut self) -> String { self.ws.refresh(); match page::static_page(&self.ws.project) { Ok(html) => json!({ "html": html }).to_string(), Err(e) => json!({ "error": e.to_string() }).to_string(), } } /// One view as a standalone SVG: `{"svg"}` or `{"error"}`. pub fn svg(&mut self, level: &str, rot: u8, light: bool) -> String { self.ws.refresh(); let level = if level.is_empty() { self.ws.project.manifest.default_level() } else { level.to_string() }; match page::svg(&self.ws.project, &level, rot % 4, light) { Ok(svg) => json!({ "svg": svg }).to_string(), Err(e) => json!({ "error": e.to_string() }).to_string(), } } /// One MCP JSON-RPC message (or a batch); "" for notifications. pub fn mcp(&mut self, msg: &str) -> String { let v = match serde_json::from_str::(msg) { Ok(v) => v, Err(e) => { return json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": e.to_string() } }) .to_string() } }; let out = match &v { Value::Array(list) => { let r: Vec = list .iter() .filter_map(|m| mcp::handle(&mut self.ws, m)) .collect(); (!r.is_empty()).then_some(Value::Array(r)) } m => mcp::handle(&mut self.ws, m), }; out.map(|v| v.to_string()).unwrap_or_default() } /// An edit made outside the Studio (code editor, watched folder); null /// removes the file. The project reloads on the next call, and a /// document change can be undone. `{"ok"}` or `{"error"}`. pub fn set_file(&mut self, path: &str, body: Option) -> String { match self.ws.store.set(path, body) { Ok(()) => json!({ "ok": true }).to_string(), Err(e) => json!({ "error": e.to_string() }).to_string(), } } pub fn file(&self, path: &str) -> Option { self.ws.store.files().get(path).cloned() } /// All files as a JSON object (path -> text). pub fn files(&self) -> String { serde_json::to_string(self.ws.store.files()).unwrap_or_default() } /// Files the Studio wrote since the last call (path -> text), for the /// page to persist. pub fn take_written(&mut self) -> String { let paths = self.ws.store.take_written(); let files = self.ws.store.files(); let out: Map = paths .into_iter() .filter_map(|p| files.get(&p).map(|t| (p, Value::String(t.clone())))) .collect(); Value::Object(out).to_string() } } /// The files of a new project with a starter scene (JSON object). #[wasm_bindgen] pub fn new_project_files(title: &str) -> String { serde_json::to_string(&isokit_project::scaffold_files(title)).unwrap_or_default() } /// The engine version. #[wasm_bindgen] pub fn engine_version() -> String { env!("CARGO_PKG_VERSION").to_string() } #[cfg(test)] mod tests { use super::*; use std::path::Path; fn demo_files() -> String { let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/demo"); serde_json::to_string(&isokit_project::read_dir_files(&dir).unwrap()).unwrap() } fn j(s: &str) -> Value { serde_json::from_str(s).unwrap() } #[test] fn demo_runs_through_the_web_api() { let mut s = Studio::new("browser: demo", &demo_files()).unwrap(); let m = j(&s.meta()); assert_eq!(m["title"], "isokit demo works"); assert!(m["catalog"].as_array().unwrap().len() > 20); assert!(m["script_error"].is_null()); assert_eq!(m["engine"], env!("CARGO_PKG_VERSION")); let n = j(&s.state())["doc"]["objects"].as_array().unwrap().len(); let v = j(&s.render("site", 1, "", true)); assert!( v["html"].as_str().unwrap().contains("data-obj=\"kiosk\""), "project prefab drawn" ); assert!(v["warnings"].as_array().unwrap().is_empty()); assert!(j(&s.render("", 2, "{\"labels\": false}", false))["error"].is_null()); // an edit is answered with the new state and handed out for saving let r = j(&s.op(r#"{"op": "add", "type": "tree", "x": 3, "y": 4}"#)); assert_eq!(r["ok"], true); assert_eq!(r["doc"]["objects"].as_array().unwrap().len(), n + 1); let w = j(&s.take_written()); let saved: Value = serde_json::from_str(w["scene.json"].as_str().unwrap()).unwrap(); assert_eq!(saved["objects"].as_array().unwrap().len(), n + 1); assert_eq!(j(&s.take_written()), json!({})); assert!(j(&s.op(r#"{"op": "add", "type": "ufo"}"#))["error"] .as_str() .unwrap() .contains("available")); // undo, and an outside edit that reloads and can be undone assert_eq!(j(&s.undo())["doc"]["objects"].as_array().unwrap().len(), n); let v0 = s.version(); let mut doc = j(&s.file("scene.json").unwrap()); doc["objects"] .as_array_mut() .unwrap() .retain(|o| o["type"] != "lamp"); assert_eq!( j(&s.set_file("scene.json", Some(doc.to_string())))["ok"], true ); assert_ne!(s.version(), v0); let st = j(&s.state()); assert!(st["doc"]["objects"] .as_array() .unwrap() .iter() .all(|o| o["type"] != "lamp")); assert_eq!(st["can_undo"], true); // a script error keeps the document editable and is reported let good = s.file("scene.rhai").unwrap(); s.set_file("scene.rhai", Some("let x = ;".into())); assert!(!j(&s.meta())["script_error"].is_null()); assert!(j(&s.render("site", 0, "", false))["error"].is_object()); s.set_file("scene.rhai", Some(good)); assert!(j(&s.meta())["script_error"].is_null()); // exports and the theme assert!(j(&s.page())["html"] .as_str() .unwrap() .contains("IsoKit.mount")); assert!(j(&s.svg("", 0, true))["svg"] .as_str() .unwrap() .starts_with("