Rename SolveCraft to SolidCraft everywhere, with compatibility shims (#389)
* WIP deep rename (not for push) * WIP2 * WIP3 * WIP4 wxs fix * rustfmt * Rename the remaining identifiers in merged crash-report code Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
27415e4dd7
commit
296683229d
464 files changed
+3408
-2956
No files matched your search
@@ -0,0 +1,481 @@
|
||||
//! SolidCraft in the browser.
|
||||
//!
|
||||
//! Runs the same [`solidcraft_ui_egui::SolveApp`] as the desktop app through eframe's web runner
|
||||
//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
|
||||
//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2, `?sample` opens the
|
||||
//! sample part.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod files;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
use std::rc::Rc;
|
||||
|
||||
use solidcraft_engine::Session;
|
||||
use solidcraft_engine::io::vfs;
|
||||
use solidcraft_ui_egui::{Services, SolveApp};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
use crate::files::{self, Files, Intent};
|
||||
|
||||
const CANVAS_ID: &str = "solidcraft_canvas";
|
||||
const LOADING_ID: &str = "solidcraft_loading";
|
||||
const DEPTH_BITS: u8 = 24;
|
||||
|
||||
/// Seconds between autosaves of unsaved work.
|
||||
const AUTOSAVE_S: f64 = 30.0;
|
||||
|
||||
thread_local! {
|
||||
/// An open design has unsaved changes: leaving the page asks first.
|
||||
static UNSAVED: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// The browser's "leave site? changes you made may not be saved" prompt while a design has
|
||||
/// unsaved changes (the page can't show its own dialog when it is being closed).
|
||||
fn ask_before_unload(window: &web_sys::Window) {
|
||||
let on_unload = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::BeforeUnloadEvent)>::new(|e: web_sys::BeforeUnloadEvent| {
|
||||
if UNSAVED.with(std::cell::Cell::get) {
|
||||
e.prevent_default();
|
||||
e.set_return_value("unsaved");
|
||||
}
|
||||
});
|
||||
let _ = window.add_event_listener_with_callback("beforeunload", on_unload.as_ref().unchecked_ref());
|
||||
// The listener lives as long as the page.
|
||||
on_unload.forget();
|
||||
}
|
||||
|
||||
const JAPANESE_FONT_PATH: &str = "./fonts/BIZUDPGothic-Regular.ttf";
|
||||
|
||||
type FontLoadResult = Rc<std::cell::RefCell<Option<Result<Vec<u8>, String>>>>;
|
||||
|
||||
struct Shell {
|
||||
app: SolveApp,
|
||||
files: Rc<Files>,
|
||||
/// When the last autosave was (seconds), and the revision it holds (none stored: `None`).
|
||||
autosaved_at: f64,
|
||||
autosaved: Option<u64>,
|
||||
/// A stored design being renamed: (its name, the new name).
|
||||
renaming: Option<(String, String)>,
|
||||
/// The rename field takes the keyboard when it appears.
|
||||
rename_focus: bool,
|
||||
/// The start page's recent designs were filled from the stored ones.
|
||||
recents_synced: bool,
|
||||
/// On-demand CJK font: fetched only when a CJK language is selected.
|
||||
font: solidcraft_ui_egui::fonts::CjkFontLoader,
|
||||
font_result: FontLoadResult,
|
||||
}
|
||||
|
||||
impl Shell {
|
||||
/// Files from the computer, writes to store, messages and the autosave.
|
||||
fn files_tick(&mut self, ctx: &egui::Context) {
|
||||
if self.files.ctx.borrow().is_none() {
|
||||
*self.files.ctx.borrow_mut() = Some(ctx.clone());
|
||||
}
|
||||
files::flush(&self.files);
|
||||
for (text, error) in self.files.messages.borrow_mut().drain(..) {
|
||||
self.app.set_status(text, error);
|
||||
}
|
||||
let incoming: Vec<_> = self.files.incoming.borrow_mut().drain(..).collect();
|
||||
for (name, bytes, intent) in incoming {
|
||||
let design = solidcraft_engine::io::is_design_path(&name);
|
||||
let path = if design && intent == Intent::Open {
|
||||
// A design from the computer is kept in the browser (a copy), so Save works.
|
||||
let path = files::free_name(&name);
|
||||
if let Err(e) = vfs::write(&path, &bytes) {
|
||||
self.app.set_status(e.to_string(), true);
|
||||
}
|
||||
files::flush(&self.files);
|
||||
path
|
||||
} else {
|
||||
let path = format!("{}{name}", files::UPLOAD);
|
||||
vfs::insert(&path, bytes);
|
||||
path
|
||||
};
|
||||
match intent {
|
||||
Intent::Open => self.app.open_path(&path),
|
||||
Intent::Insert => self.app.drop_path(&path),
|
||||
}
|
||||
self.files.panel.set(false);
|
||||
}
|
||||
if self.files.ready.get() && !self.recents_synced {
|
||||
self.recents_synced = true;
|
||||
self.sync_recents();
|
||||
}
|
||||
let now = ctx.input(|i| i.time);
|
||||
// Unsaved work is kept (at most every AUTOSAVE_S); saved work needs no autosave.
|
||||
if self.files.ready.get() && self.files.recovery.borrow().is_none() {
|
||||
let s = &self.app.session;
|
||||
let changed = s.is_dirty() && self.autosaved != Some(s.revision);
|
||||
let wait = AUTOSAVE_S - (now - self.autosaved_at);
|
||||
if changed && wait <= 0.0 {
|
||||
files::autosave(&self.files, Some((&s.doc, s.path.as_deref())));
|
||||
self.autosaved = Some(s.revision);
|
||||
self.autosaved_at = now;
|
||||
} else if changed {
|
||||
// Come back for it even when nothing else happens.
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
|
||||
} else if !s.is_dirty() && self.autosaved.is_some() {
|
||||
files::autosave(&self.files, None);
|
||||
self.autosaved = None;
|
||||
}
|
||||
}
|
||||
if !self.files.ready.get() || !self.files.incoming.borrow().is_empty() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
/// The start page's recent designs are the stored ones: the remembered order first (those
|
||||
/// still stored), then the others, newest first.
|
||||
fn sync_recents(&mut self) {
|
||||
let mut entries = self.files.entries.borrow().clone();
|
||||
entries.sort_by(|a, b| b.modified.total_cmp(&a.modified));
|
||||
let recent = &mut self.app.home.recent;
|
||||
recent.retain(|p| vfs::exists(p));
|
||||
for e in entries.iter().filter(|e| solidcraft_engine::io::is_design_path(&e.name)) {
|
||||
let p = format!("{}{}", files::OPFS, e.name);
|
||||
if !recent.contains(&p) {
|
||||
recent.push(p);
|
||||
}
|
||||
}
|
||||
recent.truncate(solidcraft_ui_egui::home::MAX_RECENT);
|
||||
}
|
||||
|
||||
/// The stored designs: open, insert, rename, delete, download; open from the computer.
|
||||
fn file_panel(&mut self, ctx: &egui::Context) {
|
||||
if !self.files.panel.get() {
|
||||
return;
|
||||
}
|
||||
let mut open = true;
|
||||
let mut act: Option<(&'static str, String)> = None;
|
||||
egui::Window::new("Files in this browser")
|
||||
.open(&mut open)
|
||||
.collapsible(false)
|
||||
.resizable(true)
|
||||
.default_width(520.0)
|
||||
.pivot(egui::Align2::CENTER_CENTER)
|
||||
.default_pos(ctx.content_rect().center())
|
||||
.show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Open from computer…").clicked() {
|
||||
files::pick_from_disk(&self.files, Intent::Open);
|
||||
}
|
||||
if ui.button("Insert from computer…").on_hover_text("STEP, 3MF or STL into the open design").clicked() {
|
||||
files::pick_from_disk(&self.files, Intent::Insert);
|
||||
}
|
||||
if ui.button("Download open design").clicked() {
|
||||
act = Some(("download_open", String::new()));
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
if !self.files.ready.get() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label("Reading the browser's storage…");
|
||||
});
|
||||
return;
|
||||
}
|
||||
let entries = self.files.entries.borrow().clone();
|
||||
if entries.is_empty() {
|
||||
ui.label("Designs you save appear here. They stay in this browser.");
|
||||
}
|
||||
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
|
||||
egui::Grid::new("opfs_files").num_columns(3).spacing([12.0, 6.0]).show(ui, |ui| {
|
||||
for e in &entries {
|
||||
match &mut self.renaming {
|
||||
Some((from, to)) if *from == e.name => {
|
||||
let r = ui.add(egui::TextEdit::singleline(to).desired_width(200.0));
|
||||
if std::mem::take(&mut self.rename_focus) {
|
||||
r.request_focus();
|
||||
}
|
||||
if r.lost_focus() {
|
||||
act = Some(if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
("rename", to.clone())
|
||||
} else {
|
||||
("rename_cancel", String::new())
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
ui.label(&e.name);
|
||||
}
|
||||
}
|
||||
ui.label(format!("{:.1} KB", e.size as f64 / 1024.0));
|
||||
ui.horizontal(|ui| {
|
||||
if ui.small_button("Open").clicked() {
|
||||
act = Some(("open", e.name.clone()));
|
||||
}
|
||||
let design = solidcraft_engine::io::is_design_path(&e.name);
|
||||
if !design && ui.small_button("Insert").on_hover_text("Into the open design (STEP, 3MF, STL)").clicked() {
|
||||
act = Some(("insert", e.name.clone()));
|
||||
}
|
||||
if ui.small_button("Rename").clicked() {
|
||||
self.renaming = Some((e.name.clone(), e.name.clone()));
|
||||
self.rename_focus = true;
|
||||
}
|
||||
if ui.small_button("Download").clicked() {
|
||||
act = Some(("download", e.name.clone()));
|
||||
}
|
||||
if ui.small_button("Delete").clicked() {
|
||||
act = Some(("delete", e.name.clone()));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if !open {
|
||||
self.files.panel.set(false);
|
||||
self.renaming = None;
|
||||
}
|
||||
let Some((what, name)) = act else { return };
|
||||
let path = format!("{}{name}", files::OPFS);
|
||||
match what {
|
||||
"open" => {
|
||||
self.app.open_path(&path);
|
||||
self.files.panel.set(false);
|
||||
}
|
||||
"insert" => {
|
||||
self.app.insert_path(&path);
|
||||
self.files.panel.set(false);
|
||||
}
|
||||
"download" => match vfs::read(&path, solidcraft_engine::io::MAX_DESIGN_BYTES) {
|
||||
Ok(b) => {
|
||||
if let Err(e) = files::download(&name, &b) {
|
||||
self.app.set_status(e, true);
|
||||
}
|
||||
}
|
||||
Err(e) => self.app.set_status(e.to_string(), true),
|
||||
},
|
||||
"download_open" => {
|
||||
let d = &self.app.session.doc;
|
||||
let bytes = solidcraft_engine::io::write_design(d);
|
||||
if let Err(e) = files::download(&format!("{}.{}", d.name, solidcraft_engine::io::DESIGN_EXT), &bytes) {
|
||||
self.app.set_status(e, true);
|
||||
}
|
||||
}
|
||||
"delete" => {
|
||||
files::delete(&self.files, &name);
|
||||
self.app.home.recent.retain(|r| *r != path);
|
||||
}
|
||||
"rename_cancel" => self.renaming = None,
|
||||
"rename" => {
|
||||
if let Some((from, _)) = self.renaming.take() {
|
||||
match files::rename(&self.files, &from, &name) {
|
||||
Ok(()) => {
|
||||
// Recent designs follow the new name.
|
||||
let (old, new) = (format!("{}{from}", files::OPFS), format!("{}{name}", files::OPFS));
|
||||
for r in &mut self.app.home.recent {
|
||||
if *r == old {
|
||||
*r = new.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => self.app.set_status(e, true),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Offer the autosave an earlier session left.
|
||||
fn recovery_prompt(&mut self, ctx: &egui::Context) {
|
||||
let Some((name, path)) = self.files.recovery.borrow().clone() else { return };
|
||||
let mut answer = None;
|
||||
egui::Window::new("Recover unsaved work?").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
ui.label(format!("“{name}” had unsaved changes when this page last closed."));
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Recover").clicked() {
|
||||
answer = Some(true);
|
||||
}
|
||||
if ui.button("Discard").clicked() {
|
||||
answer = Some(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
let Some(recover) = answer else { return };
|
||||
*self.files.recovery.borrow_mut() = None;
|
||||
if recover {
|
||||
self.app.open_path(&files::recovery_path());
|
||||
// It belongs to its own file again (saving goes there); it is unsaved.
|
||||
self.app.session.path = path;
|
||||
self.app.session.doc_mut().name = name;
|
||||
self.app.session.mark_unsaved();
|
||||
self.app.home.open = false;
|
||||
}
|
||||
files::autosave(&self.files, None);
|
||||
}
|
||||
|
||||
/// Fetch and install the CJK font when a CJK language is selected. If that fails, this
|
||||
/// session falls back to English (no tofu); the saved language preference is kept.
|
||||
fn tick_font(&mut self, ctx: &egui::Context) {
|
||||
let done = self.font_result.borrow_mut().take();
|
||||
if let Some(res) = done {
|
||||
let installed = match res {
|
||||
Ok(bytes) => solidcraft_ui_egui::fonts::install_cjk_font(ctx, bytes),
|
||||
Err(err) => {
|
||||
log::warn!("failed to fetch CJK font from {JAPANESE_FONT_PATH}: {err}");
|
||||
false
|
||||
}
|
||||
};
|
||||
if installed {
|
||||
log::info!("loaded web CJK font from {JAPANESE_FONT_PATH}");
|
||||
} else {
|
||||
log::warn!("CJK font unavailable; showing English for this session");
|
||||
}
|
||||
self.font.finished(installed, &mut self.app.preferences.language);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if self.font.tick(&mut self.app.preferences.language) {
|
||||
let slot = self.font_result.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let res = fetch_font_bytes(JAPANESE_FONT_PATH).await;
|
||||
*slot.borrow_mut() = Some(res);
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for Shell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.tick_font(ctx);
|
||||
self.app.logic(ctx);
|
||||
self.files_tick(ctx);
|
||||
UNSAVED.with(|u| u.set(solidcraft_ui_egui::documents::any_dirty(&self.app)));
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
files::take_drops(&self.files, raw);
|
||||
self.app.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.app.ui(ui);
|
||||
let ctx = ui.ctx().clone();
|
||||
self.file_panel(&ctx);
|
||||
self.recovery_prompt(&ctx);
|
||||
}
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
// Persist the user's own language, not a session-only English fallback.
|
||||
let saved = self.font.persisted_language(&self.app.preferences.language).to_owned();
|
||||
let live = std::mem::replace(&mut self.app.preferences.language, saved);
|
||||
storage.set_string(solidcraft_ui_egui::prefs::STORAGE_KEY, self.app.prefs());
|
||||
self.app.preferences.language = live;
|
||||
}
|
||||
}
|
||||
|
||||
fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Fetch font bytes asynchronously through the browser.
|
||||
async fn fetch_font_bytes(url: &str) -> Result<Vec<u8>, String> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let resp_val = wasm_bindgen_futures::JsFuture::from(window.fetch_with_str(url)).await.map_err(|e| format!("{e:?}"))?;
|
||||
let resp: web_sys::Response = resp_val.dyn_into().map_err(|_| "response cast failed")?;
|
||||
if !resp.ok() {
|
||||
return Err(format!("HTTP {}", resp.status()));
|
||||
}
|
||||
let ab_val = wasm_bindgen_futures::JsFuture::from(resp.array_buffer().map_err(|e| format!("{e:?}"))?).await.map_err(|e| format!("{e:?}"))?;
|
||||
let uint8_arr = js_sys::Uint8Array::new(&ab_val);
|
||||
Ok(uint8_arr.to_vec())
|
||||
}
|
||||
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let Some(window) = web_sys::window() else { return };
|
||||
ask_before_unload(&window);
|
||||
let Some(document) = window.document() else { return };
|
||||
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
let mut options = eframe::WebOptions { depth_buffer: DEPTH_BITS, ..Default::default() };
|
||||
if query().contains("webgl")
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
}
|
||||
let result = eframe::WebRunner::new()
|
||||
.start(
|
||||
canvas,
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
solidcraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
|
||||
let files = Rc::new(Files::default());
|
||||
files::start(&files);
|
||||
let panel = files.clone();
|
||||
let services = Services {
|
||||
// Open shows the browser's files (and the computer's, from there).
|
||||
pick_open: Some(Box::new(move || {
|
||||
panel.panel.set(true);
|
||||
None
|
||||
})),
|
||||
// Designs are kept in the browser; everything else is downloaded.
|
||||
pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
|
||||
let design = exts.contains(&solidcraft_engine::io::DESIGN_EXT);
|
||||
Some(format!("{}{name}", if design { files::OPFS } else { files::DOWNLOADS }))
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = SolveApp::new(Session::default(), services);
|
||||
if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) {
|
||||
app.load_prefs(&p);
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
// eframe's web painter has no stencil buffer.
|
||||
app.set_wgpu(rs, DEPTH_BITS, 0, 1);
|
||||
}
|
||||
if query().contains("sample") {
|
||||
if let Err(e) = app.session.run_script(&solidcraft_engine::sample::script()) {
|
||||
app.set_status(format!("sample: {e}"), true);
|
||||
}
|
||||
app.session.doc_mut().name = "Sample Plate".into();
|
||||
app.session.mark_saved();
|
||||
}
|
||||
if !query().contains("sample") {
|
||||
let rev = app.session.revision;
|
||||
app.home.show(rev);
|
||||
}
|
||||
Ok(Box::new(Shell {
|
||||
app,
|
||||
files,
|
||||
autosaved_at: f64::NEG_INFINITY,
|
||||
autosaved: None,
|
||||
renaming: None,
|
||||
rename_focus: false,
|
||||
recents_synced: false,
|
||||
font: Default::default(),
|
||||
font_result: Rc::new(std::cell::RefCell::new(None)),
|
||||
}))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
if let Some(el) = document.get_element_by_id(LOADING_ID) {
|
||||
match result {
|
||||
Ok(()) => el.remove(),
|
||||
Err(e) => {
|
||||
el.set_inner_html(&format!("<p>SolidCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>"))
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
web::start();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() {
|
||||
eprintln!("solidcraft-web only runs in the browser: build it with `trunk build --release` in apps/solidcraft-web");
|
||||
}
|
||||
Reference in new issue
Block a user