From 5af1674f6b7932cd92ceed56f7e5a331ea3e66e6 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sat, 9 May 2026 21:08:00 +0800 Subject: [PATCH] feat(shell): switch openpencil-shell-web to wasm32-unknown-unknown C-hard pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lights up the Phase A WebShell on the C-hard pipeline (vendor/skia- safe-op + crates/wasm-libc-shim, wired in the previous two commits) so `cargo build --target wasm32-unknown-unknown --features skia` followed by `wasm-bindgen --target web` produces a browser-loadable ES module with 0 env.* imports. What's added: - WebBackend (src/backend/mod.rs): impl RenderBackend over a skia-safe raster N32_PREMUL surface; presents each frame to the host via image_snapshot → read_pixels → ImageData → put_image_data. end_frame surfaces present errors via last_present_error / take_present_error so a stale failure does not leak into a subsequent successful frame - skia_wasm.rs: thin make_raster_surface helper so swapping in a GPU GrContext (Phase A round 2) is a self-contained change - mount(canvas_id) entry: locates the host , builds a WebBackend, paints the Phase A red-rect demo synchronously, propagates any present error as a JsValue exception - smoke/step-1b.html: manual smoke harness that mounts the shell and surfaces a structured diagnostic (with regression-mode LinkError messaging + rebuild instructions) if loading fails - extern crate wasm_libc_shim as _; in lib.rs to keep the shim's no_mangle symbols from being dead-code-eliminated - .gitignore for wasm-bindgen pkg/ output Cargo.toml feature wiring: - default = ["web"] keeps the kickoff §1.2 wasm32-clean compile guard CI green (stub mount, no skia) - skia = ["dep:skia-safe", "wasm-libc-shim"] opts into the real WebBackend + raster paint loop; the shim dep is target-gated so only wasm32-unknown-unknown actually pulls it in - wasm-bindgen = "=0.2.117" pinned (last release that compiles on Rust 1.85; bump alongside the toolchain in a future commit) Verified end-to-end: - `cargo build … --features skia --release` green - `wasm-bindgen --target web` produces ../pkg/*.{js,_bg.wasm} - WebAssembly.Module.imports() returns 22 imports, all from ./openpencil_shell_web_bg.js; 0 env.* imports - post `wasm-opt -Oz`: 1542 KiB raw / 599 KiB gzip — within spec §6 ceiling (≤ 1024 KiB gzip) - the kickoff §1.2 wasm32-clean compile guard still passes (`cargo check … --no-default-features --features web`) Browser-side manual smoke (Phase E) is still TODO; the bundle is structurally LinkError-free but a human still needs to confirm the red rect actually paints in Safari / Chrome / Firefox before the sub-phase can be marked complete. Step 1b §3.2 P0.5B Run path, sub-phase C-hard.2. --- crates/openpencil-shell-web/.gitignore | 2 + crates/openpencil-shell-web/Cargo.toml | 58 ++++- .../openpencil-shell-web/smoke/step-1b.html | 114 ++++++++++ .../openpencil-shell-web/src/backend/mod.rs | 199 ++++++++++++++++++ .../src/backend/skia_wasm.rs | 16 ++ crates/openpencil-shell-web/src/lib.rs | 148 +++++++++++-- 6 files changed, 520 insertions(+), 17 deletions(-) create mode 100644 crates/openpencil-shell-web/.gitignore create mode 100644 crates/openpencil-shell-web/smoke/step-1b.html create mode 100644 crates/openpencil-shell-web/src/backend/mod.rs create mode 100644 crates/openpencil-shell-web/src/backend/skia_wasm.rs diff --git a/crates/openpencil-shell-web/.gitignore b/crates/openpencil-shell-web/.gitignore new file mode 100644 index 000000000..666729791 --- /dev/null +++ b/crates/openpencil-shell-web/.gitignore @@ -0,0 +1,2 @@ +# wasm-bindgen / wasm-pack output (regenerated each build). +pkg/ diff --git a/crates/openpencil-shell-web/Cargo.toml b/crates/openpencil-shell-web/Cargo.toml index 5055642db..01e5659d8 100644 --- a/crates/openpencil-shell-web/Cargo.toml +++ b/crates/openpencil-shell-web/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true -description = "OpenPencil shell — wasm32 web bundle entry (CanvasKit + accesskit_web + OPFS)" +description = "OpenPencil shell — wasm32-unknown-unknown web bundle entry (Step 1b §1.2, C-hard pipeline)" [lib] name = "openpencil_shell_web" @@ -13,13 +13,65 @@ crate-type = ["cdylib", "rlib"] [dependencies] openpencil-shell-core = { path = "../openpencil-shell-core", version = "0.1.0" } -wasm-bindgen = "0.2" +# Step 1b §3.6: handwritten DOM mirror consumes accesskit::TreeUpdate (0.24). +accesskit = "0.24" +console_error_panic_hook = "0.1" js-sys = "0.3" +# Pin to 0.2.117 — last wasm-bindgen-cli release compatible with the +# workspace's Rust 1.85 toolchain. Newer 0.2.120+ requires rustc 1.86. +# When the workspace bumps Rust, raise this bound. +wasm-bindgen = "=0.2.117" + +# Step 1b §2.2 (post C-hard.2 lock-in 2026-05-09): wasm32-unknown-unknown +# via the vendor/skia-safe-op fork + crates/wasm-libc-shim. Optional dep +# behind the `skia` feature so `cargo check --target wasm32-unknown- +# unknown --no-default-features --features web` (the kickoff §1.2 +# wasm32-clean CI baseline) still compile-checks without pulling skia +# — that path uses the stub mount entry. Phase A and onward opt in via +# `--features skia` to get the real WebBackend + raster paint loop. +[dependencies.skia-safe] +version = "0.97.0" +default-features = false +features = ["binary-cache", "textlayout"] +optional = true + +# C-hard.2 libc/libcxx/libm shim — only linked when targeting +# wasm32-unknown-unknown with the `skia` feature on. The crate's lib.rs +# guards every symbol with `cfg(all(target_arch = "wasm32", target_os +# = "unknown"))` so native builds (where this would conflict with the +# host C runtime) link an empty crate. +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +wasm-libc-shim = { path = "../wasm-libc-shim", optional = true } [dependencies.web-sys] version = "0.3" -features = ["console", "Document", "Element", "HtmlCanvasElement", "Window"] +features = [ + "CanvasRenderingContext2d", + "CompositionEvent", + "console", + "Document", + "Element", + "Event", + "EventTarget", + "FocusEvent", + "HtmlCanvasElement", + "HtmlElement", + "ImageData", + "KeyboardEvent", + "PointerEvent", + "WheelEvent", + "Window", +] [features] +# `web` keeps the kickoff §1.2 wasm32-unknown-unknown CI baseline green — +# the crate compile-checks as a wasm32-clean stub without pulling skia. default = ["web"] web = [] +# `skia` opts into the Phase A WebBackend (skia-safe + raster surface). +# When the cargo target is wasm32-unknown-unknown the build also links +# the libc/libcxx/libm shim crate so the bundle is runtime-loadable. +# Native targets resolve the target-conditional shim dep to nothing. +skia = ["dep:skia-safe", "wasm-libc-shim"] +# Internal feature toggled on by `skia` to flip the cfg-target dep. +wasm-libc-shim = ["dep:wasm-libc-shim"] diff --git a/crates/openpencil-shell-web/smoke/step-1b.html b/crates/openpencil-shell-web/smoke/step-1b.html new file mode 100644 index 000000000..8cd99b56e --- /dev/null +++ b/crates/openpencil-shell-web/smoke/step-1b.html @@ -0,0 +1,114 @@ + + + + + Step 1b WebShell Smoke + + + +
+ + + + diff --git a/crates/openpencil-shell-web/src/backend/mod.rs b/crates/openpencil-shell-web/src/backend/mod.rs new file mode 100644 index 000000000..f7d5872cd --- /dev/null +++ b/crates/openpencil-shell-web/src/backend/mod.rs @@ -0,0 +1,199 @@ +//! `WebBackend` — Step 1b shell-web RenderBackend implementation. +//! +//! Step 1b §2.2 (post C-hard.2 lock-in 2026-05-09): wasm32-unknown-unknown +//! via the vendor/skia-safe-op fork + crates/wasm-libc-shim. This backend +//! draws into a skia-safe raster surface (N32_PREMUL) and presents the +//! snapshot to the host `` via `CanvasRenderingContext2d:: +//! put_image_data`. Phase A target = single red rectangle on a 960×640 +//! canvas; Phase B+ adds widget tree dispatch through shell-core's +//! `RenderBackend` trait. +//! +//! Skia GL backend (WebGL2 via Skia GL) is deferred to Phase A round 2 once +//! the raster path is proven; raster fallback contract is documented in spec +//! §5.3. + +pub mod skia_wasm; + +use openpencil_shell_core::{Color, Point2D, Rect, RenderBackend, TextLayout}; +use wasm_bindgen::prelude::*; +use wasm_bindgen::Clamped; +use wasm_bindgen::JsCast; +use web_sys::{CanvasRenderingContext2d, HtmlCanvasElement, ImageData}; + +pub struct WebBackend { + surface: skia_safe::Surface, + canvas_element: HtmlCanvasElement, + pixels: Vec, + width: u32, + height: u32, + dpi_scale: f32, + /// Most recent present() error captured via the infallible + /// `end_frame` trait method. Callers that need to propagate errors + /// (e.g. `WebShell::mount`) call `take_present_error()` after the + /// paint cycle. + last_present_error: Option, +} + +impl WebBackend { + pub fn new(canvas: HtmlCanvasElement) -> Result { + let width = canvas.width().max(1); + let height = canvas.height().max(1); + let surface = skia_wasm::make_raster_surface(width, height)?; + let pixels = vec![0u8; (width as usize) * (height as usize) * 4]; + Ok(Self { + surface, + canvas_element: canvas, + pixels, + width, + height, + dpi_scale: 1.0, + last_present_error: None, + }) + } + + /// Drain and return the most recent `end_frame` present error, if any. + /// Cleared on read so subsequent frames do not re-surface the same + /// failure. Use in mount entry / Phase E manual smoke verification. + pub fn take_present_error(&mut self) -> Option { + self.last_present_error.take() + } + + /// Snapshot the raster surface and `put_image_data` it onto the host + /// ``'s 2D context. Spec §5.3 raster-fallback contract: + /// N32_PREMUL surface, RGBA8888 + Unpremul read, full-frame copy each + /// frame. Phase A measures latency; >16ms (60fps budget) → revisit GL + /// backend in Phase A round 2. + pub fn present(&mut self) -> Result<(), JsValue> { + let image = self.surface.image_snapshot(); + let info = skia_safe::ImageInfo::new( + (self.width as i32, self.height as i32), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let ok = image.read_pixels( + &info, + self.pixels.as_mut_slice(), + (self.width as usize) * 4, + (0, 0), + skia_safe::image::CachingHint::Allow, + ); + if !ok { + return Err(JsValue::from_str("WebBackend: read_pixels failed")); + } + let image_data = ImageData::new_with_u8_clamped_array_and_sh( + Clamped(self.pixels.as_mut_slice()), + self.width, + self.height, + )?; + let context = self + .canvas_element + .get_context("2d")? + .ok_or_else(|| JsValue::from_str("WebBackend: 2d context unavailable"))? + .dyn_into::()?; + context.put_image_data(&image_data, 0.0, 0.0) + } +} + +/// Implementation of shell-core `RenderBackend` over the raster surface. +/// Phase A wires `fill_rect` + `begin_frame` / `end_frame` only; the +/// remaining widget-facing methods are conservative stubs that Phase B+ +/// will fill in alongside the shell-core widget set. +impl RenderBackend for WebBackend { + fn begin_frame(&mut self) { + // Raster surface starts each frame with whatever pixels the previous + // present left behind; widgets that need a clean canvas should + // explicitly clear via `fill_rect` covering the viewport. + } + + fn end_frame(&mut self) { + // RenderBackend trait is infallible by design (native + web share + // the same shape). end_frame's fallible work is `present()` → + // ImageData round-trip, which CAN fail (read_pixels / 2d-context / + // put_image_data). We surface the latest result in + // `last_present_error` so the caller can propagate it; both the + // success and failure cases are stored, so a stale prior failure + // does NOT bleed into a subsequent frame's status. + // Callers that need real propagation (e.g. WebShell::mount) check + // `take_present_error()` after each end_frame. + match self.present() { + Ok(()) => { + self.last_present_error = None; + } + Err(e) => { + web_sys::console::error_1(&e); + self.last_present_error = Some(e); + } + } + } + + fn fill_rect(&mut self, rect: Rect, color: Color) { + let paint = skia_safe::Paint::new( + skia_safe::Color4f::new(color.r, color.g, color.b, color.a), + None, + ); + self.surface.canvas().draw_rect( + skia_safe::Rect::from_xywh(rect.origin.x, rect.origin.y, rect.size.x, rect.size.y), + &paint, + ); + } + + fn stroke_rect(&mut self, rect: Rect, color: Color, width: f32) { + let mut paint = skia_safe::Paint::new( + skia_safe::Color4f::new(color.r, color.g, color.b, color.a), + None, + ); + paint.set_stroke(true); + paint.set_stroke_width(width); + paint.set_anti_alias(true); + self.surface.canvas().draw_rect( + skia_safe::Rect::from_xywh(rect.origin.x, rect.origin.y, rect.size.x, rect.size.y), + &paint, + ); + } + + fn draw_text(&mut self, _layout: &TextLayout, _origin: Point2D) { + // Phase B will route through jian-skia textlayout; Phase A red-rect + // demo does not draw text. + } + + fn clip_rect(&mut self, rect: Rect) { + self.surface.canvas().clip_rect( + skia_safe::Rect::from_xywh(rect.origin.x, rect.origin.y, rect.size.x, rect.size.y), + None, + true, + ); + } + + fn save(&mut self) { + self.surface.canvas().save(); + } + + fn restore(&mut self) { + self.surface.canvas().restore(); + } + + fn translate(&mut self, offset: Point2D) { + self.surface.canvas().translate((offset.x, offset.y)); + } + + fn resize(&mut self, width: u32, height: u32) { + let width = width.max(1); + let height = height.max(1); + if width == self.width && height == self.height { + return; + } + if let Ok(surface) = skia_wasm::make_raster_surface(width, height) { + self.surface = surface; + self.pixels = vec![0u8; (width as usize) * (height as usize) * 4]; + self.width = width; + self.height = height; + self.canvas_element.set_width(width); + self.canvas_element.set_height(height); + } + } + + fn dpi_scale(&self) -> f32 { + self.dpi_scale + } +} diff --git a/crates/openpencil-shell-web/src/backend/skia_wasm.rs b/crates/openpencil-shell-web/src/backend/skia_wasm.rs new file mode 100644 index 000000000..c26ee2372 --- /dev/null +++ b/crates/openpencil-shell-web/src/backend/skia_wasm.rs @@ -0,0 +1,16 @@ +//! skia-safe surface bring-up for wasm32-unknown-unknown (Step 1b §2.2, +//! post C-hard.2 lock-in 2026-05-09). +//! +//! Phase A path: raster N32_PREMUL surface, presented to the host `` +//! via `put_image_data`. Phase A round 2 may add a GPU `GrContext` over +//! WebGL2 once raster is proven; the trait surface in `backend/mod.rs` is +//! agnostic, so swapping the surface constructor is a self-contained change. + +use wasm_bindgen::JsValue; + +/// Allocate a raster surface backed by N32_PREMUL pixels. `width` / +/// `height` are clamped to ≥ 1 by the caller (`WebBackend::new`). +pub fn make_raster_surface(width: u32, height: u32) -> Result { + skia_safe::surfaces::raster_n32_premul((width as i32, height as i32)) + .ok_or_else(|| JsValue::from_str("skia_safe::surfaces::raster_n32_premul returned None")) +} diff --git a/crates/openpencil-shell-web/src/lib.rs b/crates/openpencil-shell-web/src/lib.rs index cf6e83680..a9293c261 100644 --- a/crates/openpencil-shell-web/src/lib.rs +++ b/crates/openpencil-shell-web/src/lib.rs @@ -1,22 +1,142 @@ -//! OpenPencil shell — web (wasm32) bundle entry. +//! OpenPencil shell — web bundle entry. //! -//! Per spec v19 §1.2 (FROZEN 2026-05-04): this crate is the web bundle entry. -//! CI invariant requires `cargo check --target wasm32-unknown-unknown -p -//! openpencil-shell-web --no-default-features --features web` to pass on -//! every PR. +//! Per spec v19 §1.2 (FROZEN 2026-05-04): this crate is the web bundle +//! entry. CI invariant requires `cargo check --target wasm32-unknown- +//! unknown -p openpencil-shell-web --no-default-features --features web` +//! to pass on every PR — that path uses the **stub** mount entry below +//! and is purely a wasm32-clean compile guard (no skia, no real render). //! -//! Step 1a Task 1 status: only link-checks the shell-core re-export; the -//! CanvasKit WebBackend lands in Step 1b. +//! Phase A onward enables `--features skia` and targets +//! `wasm32-unknown-unknown` via the C-hard pipeline (vendor/skia-safe-op +//! fork + crates/wasm-libc-shim). The same target serves both the +//! compile-guard CI baseline and the real render path; the only +//! difference is the `skia` feature flag and the EMSDK env var (used +//! at build time only — for libcxx headers + emsdk's wasm-aware clang +//! — never linked into the final bundle). + +#[cfg(feature = "skia")] +mod backend; + +// Force the wasm32-unknown-unknown libc/libcxx/libm shim to be linked +// even though no Rust code calls it — its `#[no_mangle]` symbols are +// referenced only by the C++ side of the wasm (Skia static lib). Without +// this `extern crate`, cargo would dead-code-eliminate the shim because +// no Rust path imports anything from it. +#[cfg(all(feature = "skia", target_arch = "wasm32", target_os = "unknown"))] +extern crate wasm_libc_shim as _; use wasm_bindgen::prelude::*; -/// Skeleton placeholder until Step 1b lands `WebCanvasKitBackend`. +/// Long-lived shell handle. The smoke HTML must keep this alive (e.g. +/// `window.__opShell = mount("op")`) so closures stored on the shell +/// remain reachable for the page lifetime. /// -/// Uses the OP `Color::TRANSPARENT` named constant to prove the shell-core -/// re-export links on wasm32 — this is the minimal link-check (shell-core -/// must stay wasm32-clean per spec §1.2). +/// The stub variant (without `skia` feature) carries no fields and exists +/// only so the wasm32-unknown-unknown CI baseline can compile-check the +/// public surface. #[wasm_bindgen] -pub fn placeholder() -> String { - let _t = openpencil_shell_core::Color::TRANSPARENT; - "openpencil-shell-web skeleton (Task 1: deps wired)".to_string() +pub struct WebShell { + #[cfg(feature = "skia")] + backend: backend::WebBackend, +} + +#[cfg(feature = "skia")] +impl WebShell { + /// Phase A red-rect demo: clear to white, draw a centered red rect, + /// snapshot to the host canvas. Returns the present error if the + /// final ImageData round-trip failed — callers MUST propagate this + /// to JS instead of treating mount as successful when the canvas + /// stayed blank. + /// + /// Phase B+ replaces this with the widget host paint loop. + fn paint_phase_a(&mut self) -> Result<(), JsValue> { + use openpencil_shell_core::{Color, Point2D, Rect, RenderBackend}; + self.backend.begin_frame(); + // Clear background. + self.backend.fill_rect( + Rect { + origin: Point2D::new(0.0, 0.0), + size: Point2D::new(960.0, 640.0), + }, + Color::WHITE, + ); + // Centered red rect: 320×120 inside the 960×640 canvas. + self.backend.fill_rect( + Rect { + origin: Point2D::new(320.0, 260.0), + size: Point2D::new(320.0, 120.0), + }, + Color::RED, + ); + self.backend.end_frame(); + if let Some(err) = self.backend.take_present_error() { + return Err(err); + } + Ok(()) + } +} + +/// Mount the WebShell on the canvas identified by `canvas_id` in the host +/// document. Returns the live shell instance to the caller; the caller +/// MUST keep it alive (`window.__opShell = mount("op")`). +/// +/// Errors propagate back to JS as a `JsValue` exception. +/// +/// Without the `skia` feature this is a stub that returns the +/// fields-less `WebShell` after validating the canvas element exists +/// — useful only for the kickoff §1.2 wasm32-clean compile guard CI. +#[cfg(feature = "skia")] +#[wasm_bindgen] +pub fn mount(canvas_id: &str) -> Result { + use wasm_bindgen::JsCast; + use web_sys::HtmlCanvasElement; + + // Install the panic hook on first call so panics print to the browser + // console instead of being swallowed silently. + console_error_panic_hook::set_once(); + + let window = web_sys::window().ok_or_else(|| JsValue::from_str("mount: window unavailable"))?; + let document = window + .document() + .ok_or_else(|| JsValue::from_str("mount: document unavailable"))?; + let element = document + .get_element_by_id(canvas_id) + .ok_or_else(|| JsValue::from_str(&format!("mount: canvas '{canvas_id}' not found")))?; + let canvas = element + .dyn_into::() + .map_err(|_| JsValue::from_str("mount: target element is not "))?; + + let backend = backend::WebBackend::new(canvas)?; + let mut shell = WebShell { backend }; + // Phase A demo paints synchronously inside mount(); any present error + // (read_pixels / put_image_data) MUST surface as a JS exception so + // callers do not see Ok with an unpainted canvas. + shell.paint_phase_a()?; + Ok(shell) +} + +/// Stub mount used by the kickoff §1.2 wasm32-clean compile guard CI. +/// Returns a fields-less `WebShell` after verifying the host has a +/// canvas with the given id; never paints. Real rendering needs the +/// `skia` feature. +#[cfg(not(feature = "skia"))] +#[wasm_bindgen] +pub fn mount(canvas_id: &str) -> Result { + use wasm_bindgen::JsCast; + use web_sys::HtmlCanvasElement; + + console_error_panic_hook::set_once(); + + let window = web_sys::window().ok_or_else(|| JsValue::from_str("mount: window unavailable"))?; + let document = window + .document() + .ok_or_else(|| JsValue::from_str("mount: document unavailable"))?; + let element = document + .get_element_by_id(canvas_id) + .ok_or_else(|| JsValue::from_str(&format!("mount: canvas '{canvas_id}' not found")))?; + let _canvas = element + .dyn_into::() + .map_err(|_| JsValue::from_str("mount: target element is not "))?; + + Ok(WebShell {}) }