mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 19:57:05 -06:00
M0.1/M0.9: xtask (layers, wasm, ci, stats), agent docs, NOTICE, README, control protocol doc
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
bea0c16636
commit
8cd8f8f8a7
@@ -4,8 +4,15 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "DrawCraft command line: MCP server (stdio) and headless batch runs"
|
||||
|
||||
[[bin]]
|
||||
name = "drawcraft-cli"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
drawcraft-mcp = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
[package]
|
||||
name = "drawcraft-web"
|
||||
description = "DrawCraft in the browser (eframe web runner, WebGPU with WebGL2 fallback)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -8,4 +9,21 @@ publish = false
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "drawcraft-web"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
drawcraft-engine = { workspace = true }
|
||||
drawcraft-ui-egui = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Build with: trunk build --release (from apps/drawcraft-web)
|
||||
[build]
|
||||
target = "index.html"
|
||||
dist = "../../dist/web"
|
||||
public_url = "./"
|
||||
filehash = true
|
||||
|
||||
[serve]
|
||||
addresses = ["127.0.0.1"]
|
||||
port = 8766
|
||||
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>DrawCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="drawcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
|
||||
#drawcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#drawcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
color: #b8b8b8; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="drawcraft_canvas"></canvas>
|
||||
<div id="drawcraft_loading">Loading DrawCraft…</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +1,27 @@
|
||||
fn main() {}
|
||||
//! DrawCraft in the browser.
|
||||
//!
|
||||
//! Runs the same [`drawcraft_ui_egui::DrawcraftApp`] as the desktop app through eframe's web
|
||||
//! runner (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release`
|
||||
//! from this directory; see `docs/development.md` ("Web build").
|
||||
//!
|
||||
//! Differences from the desktop app:
|
||||
//! - no TCP control server (browsers can't listen on sockets);
|
||||
//! - File → Open uses the browser file picker; bytes arrive asynchronously through
|
||||
//! `Services::inbox`;
|
||||
//! - Save / Export trigger a browser download;
|
||||
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox.
|
||||
//!
|
||||
//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU.
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
web::start();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() {
|
||||
eprintln!("drawcraft-web only runs in the browser: build it with `trunk build --release` in apps/drawcraft-web");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use drawcraft_engine::Session;
|
||||
use drawcraft_ui_egui::{DrawcraftApp, Services};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
const OPEN_EXTS: &[&str] = &["drawcraft", "svg", "png", "jpg", "jpeg", "gif", "webp"];
|
||||
const CANVAS_ID: &str = "drawcraft_canvas";
|
||||
const LOADING_ID: &str = "drawcraft_loading";
|
||||
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
|
||||
log::error!("no document");
|
||||
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::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| {
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
log::info!("drawcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
|
||||
}
|
||||
let inbox: Inbox = Arc::default();
|
||||
let app = DrawcraftApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
Ok(Box::new(WebShell { app, inbox }))
|
||||
}),
|
||||
)
|
||||
.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>DrawCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>"
|
||||
)),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
|
||||
/// and feed them through the inbox.
|
||||
struct WebShell {
|
||||
app: DrawcraftApp,
|
||||
inbox: Inbox,
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
|
||||
for f in dropped {
|
||||
let inbox = self.inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let name = f
|
||||
.path()
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "dropped".into());
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(raw);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.app.ui(ui);
|
||||
}
|
||||
}
|
||||
|
||||
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
Services {
|
||||
open_async: Some(Box::new(move || {
|
||||
let inbox = open_inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(file) = rfd::AsyncFileDialog::new().add_filter("All supported", OPEN_EXTS).pick_file().await else {
|
||||
return;
|
||||
};
|
||||
let bytes = file.read().await;
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
})),
|
||||
download: Some(Box::new(|name: &str, bytes: &[u8]| {
|
||||
if let Err(e) = download(name, bytes) {
|
||||
log::error!("download of {name} failed: {e}");
|
||||
}
|
||||
})),
|
||||
inbox: Some(inbox),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger a browser download of `bytes` named after the last component of `path`.
|
||||
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let name = std::path::Path::new(path)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "drawcraft".into());
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let document = window.document().ok_or("no document")?;
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
opts.set_type(mime_for(&name));
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
|
||||
let a: web_sys::HtmlAnchorElement =
|
||||
document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
|
||||
a.set_href(&url);
|
||||
a.set_download(&name);
|
||||
a.style().set_property("display", "none").map_err(js)?;
|
||||
let body = document.body().ok_or("no body")?;
|
||||
body.append_child(&a).map_err(js)?;
|
||||
a.click();
|
||||
a.remove();
|
||||
// Revoke after the click has been dispatched; the download keeps its own reference.
|
||||
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
web_sys::Url::revoke_object_url(&url).ok();
|
||||
});
|
||||
window
|
||||
.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000)
|
||||
.map_err(js)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mime_for(name: &str) -> &'static str {
|
||||
match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("png") => "image/png",
|
||||
Some("jpg" | "jpeg") => "image/jpeg",
|
||||
Some("pdf") => "application/pdf",
|
||||
Some("drawcraft") => "application/json",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user