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:
bherbruckandClaude Opus 5.5 committed 2026-10-08 21:15:18 +00:00
1 parent d191ae571e
commit c9ed3d154d
13 files changed
+964 -42

No files matched your search

+10 -10
View File
@@ -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:?}")}))
}
+4 -4
View File
@@ -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`")),
};
+1 -1
View File
@@ -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()}))
+3 -3
View File
@@ -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"));
+4 -4
View File
@@ -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()}))
+1
View File
@@ -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};
+5
View File
@@ -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)
}
+102
View File
@@ -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
View File
@@ -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)));