Web files: designs live in the browser (OPFS), files come in by picker or drop and go out as downloads
- solvecraft_io::vfs: the engine's file access (disk natively; in the browser an in-memory set of files the host loads and stores). File commands and write_atomic go through it. - Web host (apps/solvecraft-web/src/files.rs): stored designs are read from OPFS at start (storage.persist() asked for) and written back when a command writes one; Save/Save As keep designs in the browser, exports (STEP, STL, 3MF, OBJ, ...) download as files. - File > Open shows "Files in this browser": open, insert, rename, download, delete, plus open or insert from the computer (showOpenFilePicker where there is one, an <input type=file> otherwise). Files dropped on the page are inserted or opened. - Unsaved work is autosaved to OPFS and offered again after a reload or crash. - Start page recents come from the stored designs, with thumbnails. In the browser the samples are built when opened (a picture blocks the page there, and a kernel panic can't be caught). - Pages without OPFS (http on a network address, old browsers) keep designs in local storage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
d191ae571e
commit
c9ed3d154d
13 files changed
+964
-42
No files matched your search
@@ -51,11 +51,11 @@ fn path_arg<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> {
|
||||
|
||||
/// Read a STEP file as an Import feature.
|
||||
fn read_step(path: &str) -> Result<solvecraft_io::StepFeature> {
|
||||
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if meta.len() > solvecraft_io::MAX_STEP_BYTES as u64 {
|
||||
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if meta > solvecraft_io::MAX_STEP_BYTES as u64 {
|
||||
return Err(EngineError::Other(format!("{path}: file too large")));
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
solvecraft_io::step_import_feature(&bytes, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
|
||||
@@ -80,11 +80,11 @@ fn add_step(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
|
||||
|
||||
/// Add a MeshImport feature for a 3MF or STL file.
|
||||
fn add_mesh(s: &mut Session, path: &str, name: Option<&str>) -> Result<Value> {
|
||||
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if meta.len() > solvecraft_io::MAX_3MF_BYTES as u64 {
|
||||
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if meta > solvecraft_io::MAX_3MF_BYTES as u64 {
|
||||
return Err(EngineError::Other(format!("{path}: file too large")));
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let mf = solvecraft_io::mesh_import_feature(&bytes, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let fname = name.filter(|n| !n.trim().is_empty()).unwrap_or(&mf.name).to_string();
|
||||
let id = s.doc_mut().add_feature(mf.kind, Some(&fname))?;
|
||||
@@ -132,11 +132,11 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
|
||||
return Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors, "bodies": r["bodies"], "warnings": r["warnings"]}));
|
||||
}
|
||||
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if meta.len() as usize > solvecraft_io::MAX_DESIGN_BYTES {
|
||||
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if meta as usize > solvecraft_io::MAX_DESIGN_BYTES {
|
||||
return Err(EngineError::Other(format!("{path}: file too large")));
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let doc = solvecraft_io::read_design(&bytes)?;
|
||||
*s = Session::new(doc);
|
||||
s.path = Some(path.to_string());
|
||||
@@ -175,7 +175,7 @@ fn export_to(s: &Session, path: &str, format: Format, bodies: &[String]) -> Resu
|
||||
// Bodies carry the colour they are shown in (appearance, material or imported).
|
||||
solvecraft_io::export(&s.doc.painted(&s.world_state()), bodies, format, &name)?
|
||||
};
|
||||
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
solvecraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len(), "format": format!("{format:?}")}))
|
||||
}
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
other => return Err(bad(cmd, format!("unknown format `{other}` (csv or json)"))),
|
||||
};
|
||||
if let Some(path) = path {
|
||||
std::fs::write(path, &text).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
solvecraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
}
|
||||
Ok(json!({"format": format, "path": path, "text": text}))
|
||||
}
|
||||
@@ -229,11 +229,11 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = match (str_(p, "text"), str_(p, "path")) {
|
||||
(Some(t), _) => t.to_string(),
|
||||
(None, Some(path)) => {
|
||||
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if meta.len() > 16 << 20 {
|
||||
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if meta > 16 << 20 {
|
||||
return Err(bad(cmd, "file too large"));
|
||||
}
|
||||
std::fs::read_to_string(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
|
||||
solvecraft_io::vfs::read_to_string(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
|
||||
}
|
||||
_ => return Err(bad(cmd, "give `text` or `path`")),
|
||||
};
|
||||
|
||||
@@ -369,7 +369,7 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sh = sheet_of(s, p, cmd)?;
|
||||
let text = flat_dxf(&sh)?;
|
||||
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
|
||||
std::fs::write(path, &text).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
solvecraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
return Ok(json!({"path": path, "bends": sh.bends().len()}));
|
||||
}
|
||||
Ok(json!({"dxf": text, "bends": sh.bends().len()}))
|
||||
|
||||
@@ -31,11 +31,11 @@ fn image_bytes(p: &Value, cmd: &str) -> Result<Vec<u8>> {
|
||||
base64_decode(d).ok_or_else(|| bad(cmd, "`data` is not base64"))?
|
||||
} else {
|
||||
let path = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "give `path` or `data`"))?;
|
||||
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if meta.len() as usize > MAX_CANVAS_BYTES {
|
||||
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if meta as usize > MAX_CANVAS_BYTES {
|
||||
return Err(bad(cmd, "the image is too large"));
|
||||
}
|
||||
std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
|
||||
solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
|
||||
};
|
||||
if b.len() > MAX_CANVAS_BYTES {
|
||||
return Err(bad(cmd, "the image is too large"));
|
||||
|
||||
@@ -34,11 +34,11 @@ fn source_text(p: &Value, cmd: &str) -> Result<String> {
|
||||
return Ok(t.to_string());
|
||||
}
|
||||
let path = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "give `path` or `text`"))?;
|
||||
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if meta.len() as usize > MAX_DRAWING_BYTES {
|
||||
let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if meta as usize > MAX_DRAWING_BYTES {
|
||||
return Err(EngineError::Other(format!("{path}: file too large")));
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok(String::from_utf8_lossy(&bytes).to_string())
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let text = solvecraft_io::write_dxf(&geom);
|
||||
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
|
||||
std::fs::write(path, &text).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
solvecraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
return Ok(json!({"path": path, "entities": geom.len()}));
|
||||
}
|
||||
Ok(json!({"dxf": text, "entities": geom.len()}))
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod safe_write;
|
||||
mod sketch2d;
|
||||
mod svg;
|
||||
mod threemf;
|
||||
pub mod vfs;
|
||||
|
||||
pub use dxf::{read_dxf, write_dxf};
|
||||
pub use safe_write::{backup_path, write_atomic};
|
||||
|
||||
@@ -87,6 +87,11 @@ fn write_steps(path: &Path, bytes: &[u8], backup: bool, stop: Option<Step>) -> s
|
||||
/// Write `bytes` to `path` atomically; with `backup`, the file being replaced is kept as
|
||||
/// `<path>.bak` first.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8], backup: bool) -> std::io::Result<()> {
|
||||
// In the browser the host stores files (one write is already atomic there).
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
let _ = backup;
|
||||
return crate::vfs::write(&path.to_string_lossy(), bytes);
|
||||
}
|
||||
write_steps(path, bytes, backup, None)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! File access for the engine. Natively it is the disk. In the browser (wasm32) there is no
|
||||
//! file system to call synchronously, so files live in memory here: the web host loads them
|
||||
//! (from the browser's private storage, a file picker or a drop) before a command reads them,
|
||||
//! and takes what commands wrote ([`take_writes`]) to store it or hand it out as a download.
|
||||
|
||||
use std::io::{Error, ErrorKind, Result};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod imp {
|
||||
use super::*;
|
||||
|
||||
pub fn read(path: &str) -> Result<Vec<u8>> {
|
||||
std::fs::read(path)
|
||||
}
|
||||
pub fn write(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
std::fs::write(path, bytes)
|
||||
}
|
||||
pub fn len(path: &str) -> Result<u64> {
|
||||
std::fs::metadata(path).map(|m| m.len())
|
||||
}
|
||||
pub fn exists(path: &str) -> bool {
|
||||
std::path::Path::new(path).exists()
|
||||
}
|
||||
pub fn remove(path: &str) -> Result<()> {
|
||||
std::fs::remove_file(path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod imp {
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::*;
|
||||
|
||||
thread_local! {
|
||||
static FILES: RefCell<BTreeMap<String, Vec<u8>>> = const { RefCell::new(BTreeMap::new()) };
|
||||
static WRITES: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
fn missing(path: &str) -> Error {
|
||||
Error::new(ErrorKind::NotFound, format!("{path}: no such file in this browser"))
|
||||
}
|
||||
|
||||
pub fn read(path: &str) -> Result<Vec<u8>> {
|
||||
FILES.with(|f| f.borrow().get(path).cloned()).ok_or_else(|| missing(path))
|
||||
}
|
||||
pub fn write(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
FILES.with(|f| f.borrow_mut().insert(path.to_string(), bytes.to_vec()));
|
||||
WRITES.with(|w| {
|
||||
let mut w = w.borrow_mut();
|
||||
w.retain(|p| p != path);
|
||||
w.push(path.to_string());
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
pub fn len(path: &str) -> Result<u64> {
|
||||
FILES.with(|f| f.borrow().get(path).map(|b| b.len() as u64)).ok_or_else(|| missing(path))
|
||||
}
|
||||
pub fn exists(path: &str) -> bool {
|
||||
FILES.with(|f| f.borrow().contains_key(path))
|
||||
}
|
||||
pub fn remove(path: &str) -> Result<()> {
|
||||
FILES.with(|f| f.borrow_mut().remove(path)).map(|_| ()).ok_or_else(|| missing(path))
|
||||
}
|
||||
pub fn insert(path: &str, bytes: Vec<u8>) {
|
||||
FILES.with(|f| f.borrow_mut().insert(path.to_string(), bytes));
|
||||
}
|
||||
pub fn take_writes() -> Vec<(String, Vec<u8>)> {
|
||||
let paths = WRITES.with(|w| std::mem::take(&mut *w.borrow_mut()));
|
||||
paths.into_iter().filter_map(|p| FILES.with(|f| f.borrow().get(&p).cloned()).map(|b| (p, b))).collect()
|
||||
}
|
||||
pub fn list(prefix: &str) -> Vec<(String, u64)> {
|
||||
FILES.with(|f| f.borrow().iter().filter(|(p, _)| p.starts_with(prefix)).map(|(p, b)| (p.clone(), b.len() as u64)).collect())
|
||||
}
|
||||
}
|
||||
|
||||
pub use imp::*;
|
||||
|
||||
/// A file's text.
|
||||
pub fn read_to_string(path: &str) -> Result<String> {
|
||||
String::from_utf8(read(path)?).map_err(|e| Error::new(ErrorKind::InvalidData, e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn files_round_trip() {
|
||||
let dir = std::env::temp_dir().join(format!("solvecraft-vfs-{}", std::process::id()));
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let p = dir.join("a.txt").to_string_lossy().to_string();
|
||||
assert!(!super::exists(&p));
|
||||
assert!(super::read(&p).is_err());
|
||||
super::write(&p, b"hello").unwrap();
|
||||
assert!(super::exists(&p));
|
||||
assert_eq!(super::len(&p).unwrap(), 5);
|
||||
assert_eq!(super::read_to_string(&p).unwrap(), "hello");
|
||||
super::remove(&p).unwrap();
|
||||
assert!(!super::exists(&p));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
+40
-12
@@ -180,7 +180,7 @@ pub fn open_sample(app: &mut SolveApp, i: usize) {
|
||||
}
|
||||
|
||||
fn open_recent(app: &mut SolveApp, path: &str) {
|
||||
if !std::path::Path::new(path).exists() {
|
||||
if !solvecraft_engine::io::vfs::exists(path) {
|
||||
app.home.recent.retain(|p| p != path);
|
||||
app.set_status(format!("{path} is no longer there"), true);
|
||||
return;
|
||||
@@ -198,11 +198,15 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
poll(app, ui.ctx());
|
||||
let t = Tokens::get();
|
||||
let samples = solvecraft_engine::sample::samples();
|
||||
for i in 0..samples.len() {
|
||||
want(&mut app.home, &format!("sample:{i}"));
|
||||
// In the browser a picture blocks the page while it builds (no threads) and a kernel
|
||||
// failure there can't be caught: samples are built when they are opened.
|
||||
if !cfg!(target_arch = "wasm32") {
|
||||
for i in 0..samples.len() {
|
||||
want(&mut app.home, &format!("sample:{i}"));
|
||||
}
|
||||
}
|
||||
let recent = app.home.recent.clone();
|
||||
for p in recent.iter().filter(|p| std::path::Path::new(p.as_str()).exists()) {
|
||||
for p in recent.iter().filter(|p| solvecraft_engine::io::vfs::exists(p)) {
|
||||
want(&mut app.home, p);
|
||||
}
|
||||
let mut action: Option<Box<dyn FnOnce(&mut SolveApp)>> = None;
|
||||
@@ -266,9 +270,19 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let path = std::path::Path::new(p);
|
||||
let name = path.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| p.clone());
|
||||
let folder = path.parent().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let missing = !path.exists();
|
||||
let sub = if missing { "missing".to_string() } else { folder };
|
||||
if card(ui, app.home.thumbs.get(p.as_str()).and_then(Option::as_ref), &name, &sub, missing).on_hover_text(p.as_str()).clicked() {
|
||||
let missing = !solvecraft_engine::io::vfs::exists(p);
|
||||
let sub = if missing {
|
||||
"missing".to_string()
|
||||
} else if p.starts_with("/opfs/") {
|
||||
// The web build keeps designs in the browser's own storage.
|
||||
"in this browser".to_string()
|
||||
} else {
|
||||
folder
|
||||
};
|
||||
if card(ui, Pic::Ready(app.home.thumbs.get(p.as_str()).and_then(Option::as_ref)), &name, &sub, missing)
|
||||
.on_hover_text(p.as_str())
|
||||
.clicked()
|
||||
{
|
||||
let p = p.clone();
|
||||
action = Some(Box::new(move |app: &mut SolveApp| open_recent(app, &p)));
|
||||
}
|
||||
@@ -281,7 +295,11 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let key = format!("sample:{i}");
|
||||
let busy = app.home.wanted == Some(i);
|
||||
let sub = if busy { "opening…" } else { s.about };
|
||||
if card(ui, app.home.thumbs.get(&key).and_then(Option::as_ref), s.name, sub, false).on_hover_text(s.about).clicked() {
|
||||
let tex = match app.home.thumbs.get(&key) {
|
||||
Some(t) => Pic::Ready(t.as_ref()),
|
||||
None => Pic::Icon("box"),
|
||||
};
|
||||
if card(ui, tex, s.name, sub, false).on_hover_text(s.about).clicked() {
|
||||
action = Some(Box::new(move |app: &mut SolveApp| open_sample(app, i)));
|
||||
}
|
||||
});
|
||||
@@ -313,8 +331,14 @@ fn grid(ui: &mut egui::Ui, n: usize, mut each: impl FnMut(&mut egui::Ui, usize))
|
||||
}
|
||||
}
|
||||
|
||||
/// A card's picture: rendered (`None` while it renders), or an icon until it is asked for.
|
||||
enum Pic<'a> {
|
||||
Ready(Option<&'a egui::TextureHandle>),
|
||||
Icon(&'static str),
|
||||
}
|
||||
|
||||
/// A design card: its picture (a spinner while it renders), name and a line under it.
|
||||
fn card(ui: &mut egui::Ui, tex: Option<&egui::TextureHandle>, name: &str, sub: &str, dim: bool) -> egui::Response {
|
||||
fn card(ui: &mut egui::Ui, pic: Pic, name: &str, sub: &str, dim: bool) -> egui::Response {
|
||||
let t = Tokens::get();
|
||||
let size = vec2(THUMB[0] as f32 + 16.0, THUMB[1] as f32 + 62.0);
|
||||
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
|
||||
@@ -327,12 +351,16 @@ fn card(ui: &mut egui::Ui, tex: Option<&egui::TextureHandle>, name: &str, sub: &
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
let img = egui::Rect::from_min_size(r.min + vec2(8.0, 8.0), vec2(THUMB[0] as f32, THUMB[1] as f32));
|
||||
match tex {
|
||||
Some(tx) => {
|
||||
match pic {
|
||||
Pic::Icon(icon) => {
|
||||
ui.painter().rect_filled(img, CornerRadius::same(4), t.panel_header);
|
||||
crate::icons::paint(ui.painter(), egui::Rect::from_center_size(img.center(), vec2(56.0, 56.0)), icon, t.icon, t.icon_fill, t.icon);
|
||||
}
|
||||
Pic::Ready(Some(tx)) => {
|
||||
let tint = if dim { Color32::from_white_alpha(90) } else { Color32::WHITE };
|
||||
ui.painter().image(tx.id(), img, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), tint);
|
||||
}
|
||||
None => {
|
||||
Pic::Ready(None) => {
|
||||
ui.painter().rect_filled(img, CornerRadius::same(4), t.panel_header);
|
||||
if !dim {
|
||||
egui::Spinner::new().size(18.0).paint_at(ui, egui::Rect::from_center_size(img.center(), vec2(18.0, 18.0)));
|
||||
|
||||
Reference in new issue
Block a user