feat(shell): switch openpencil-shell-web to wasm32-unknown-unknown C-hard pipeline
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 <canvas> 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 <canvas>, 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.
This commit is contained in:
parent
8ac685ff84
commit
5af1674f6b
2
crates/openpencil-shell-web/.gitignore
vendored
Normal file
2
crates/openpencil-shell-web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# wasm-bindgen / wasm-pack output (regenerated each build).
|
||||
pkg/
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
114
crates/openpencil-shell-web/smoke/step-1b.html
Normal file
114
crates/openpencil-shell-web/smoke/step-1b.html
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Step 1b WebShell Smoke</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: #fafafa;
|
||||
font:
|
||||
14px/1.5 -apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
}
|
||||
#status {
|
||||
padding: 16px;
|
||||
}
|
||||
#status pre {
|
||||
background: #fff;
|
||||
border: 1px solid #ccc;
|
||||
padding: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
width: 960px;
|
||||
height: 640px;
|
||||
border: 1px solid #111;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="status"></div>
|
||||
<canvas id="op" width="960" height="640"></canvas>
|
||||
<script type="module">
|
||||
// Phase A smoke harness for the WebShell red-rect demo.
|
||||
//
|
||||
// CURRENT STATE (post C-hard.2, 2026-05-09):
|
||||
// - Skia C++ + skia-bindings + skia-safe + shell-web build to
|
||||
// wasm32-unknown-unknown via the vendor/skia-safe-op fork
|
||||
// (build_support/platform/wasm_unknown.rs).
|
||||
// - The libc / libcxx / libm shim crate (crates/wasm-libc-shim)
|
||||
// resolves the previously-missing env.* imports. Verified via
|
||||
// `WebAssembly.Module.imports()`: 22 imports, all from
|
||||
// `./openpencil_shell_web_bg.js`; 0 env.* imports.
|
||||
// - The bundle is structurally LinkError-free; a LinkError here
|
||||
// is now a REGRESSION signal, not the expected state.
|
||||
//
|
||||
// What this harness verifies:
|
||||
// - module loads (LinkError ⇒ regression in libc shim or fork)
|
||||
// - mount() returns a live WebShell
|
||||
// - paint_phase_a draws a red rect via skia raster + put_image_data
|
||||
//
|
||||
// What this harness does NOT verify (Phase E manual smoke):
|
||||
// - the red rect is actually visible to a human in a browser tab
|
||||
// - latency is within the 16ms / 60fps budget
|
||||
// - DPI scale + canvas resize behave correctly
|
||||
//
|
||||
// Round 2 C-R2-1 fix: assign the returned WebShell to
|
||||
// `window.__opShell` so the GC keeps it alive for the page
|
||||
// lifetime; the shell may hold closures into the wasm module.
|
||||
const status = document.getElementById('status');
|
||||
const log = (cls, msg) => {
|
||||
const block = document.createElement('pre');
|
||||
block.className = cls;
|
||||
block.textContent = msg;
|
||||
status.appendChild(block);
|
||||
};
|
||||
try {
|
||||
const mod = await import('../pkg/openpencil_shell_web.js');
|
||||
await mod.default();
|
||||
window.__opShell = mod.mount('op');
|
||||
log('ok', 'WebShell mounted; canvas should show centered red rect.');
|
||||
} catch (e) {
|
||||
const isLinkError =
|
||||
e instanceof WebAssembly.LinkError ||
|
||||
(e && e.constructor && e.constructor.name === 'LinkError') ||
|
||||
(e && e.message && e.message.startsWith('WebAssembly.instantiate()'));
|
||||
const header = isLinkError
|
||||
? 'WebShell wasm bundle failed to link — REGRESSION (post C-hard.2 the bundle is supposed to be LinkError-free).'
|
||||
: 'WebShell loader could not be imported.';
|
||||
log(
|
||||
'error',
|
||||
[
|
||||
header,
|
||||
'',
|
||||
'Expected file: ../pkg/openpencil_shell_web.js',
|
||||
'',
|
||||
'How to rebuild the bundle (when the file is missing or stale):',
|
||||
' 1. brew install emscripten # 5.0.7 + EMSDK shim',
|
||||
' 2. mkdir -p ~/.emsdk/upstream && \\',
|
||||
' ln -sfn /opt/homebrew/opt/emscripten/libexec ~/.emsdk/upstream/emscripten',
|
||||
' 3. EMSDK="$HOME/.emsdk" cargo build -p openpencil-shell-web \\',
|
||||
' --target wasm32-unknown-unknown --features skia --release',
|
||||
' 4. (one-time) ln -sf libfoo.wasm.a libfoo.a in target/.../skia-bindings-*/out/skia/',
|
||||
' 5. wasm-bindgen --target web \\',
|
||||
' --out-dir crates/openpencil-shell-web/pkg \\',
|
||||
' target/wasm32-unknown-unknown/release/openpencil_shell_web.wasm',
|
||||
'',
|
||||
'A LinkError after a clean rebuild means a new env.* import',
|
||||
'snuck back in (skia upgrade, new shim gap, etc). Inspect via',
|
||||
' wasm-objdump --details --section=Import ../pkg/openpencil_shell_web_bg.wasm',
|
||||
'and add the missing symbol to crates/wasm-libc-shim/src/imp.rs.',
|
||||
'',
|
||||
'Underlying error: ' + (e && e.message ? e.message : String(e)),
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
199
crates/openpencil-shell-web/src/backend/mod.rs
Normal file
199
crates/openpencil-shell-web/src/backend/mod.rs
Normal file
|
|
@ -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 `<canvas>` 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<u8>,
|
||||
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<JsValue>,
|
||||
}
|
||||
|
||||
impl WebBackend {
|
||||
pub fn new(canvas: HtmlCanvasElement) -> Result<Self, JsValue> {
|
||||
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<JsValue> {
|
||||
self.last_present_error.take()
|
||||
}
|
||||
|
||||
/// Snapshot the raster surface and `put_image_data` it onto the host
|
||||
/// `<canvas>`'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::<CanvasRenderingContext2d>()?;
|
||||
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
|
||||
}
|
||||
}
|
||||
16
crates/openpencil-shell-web/src/backend/skia_wasm.rs
Normal file
16
crates/openpencil-shell-web/src/backend/skia_wasm.rs
Normal file
|
|
@ -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 `<canvas>`
|
||||
//! 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::Surface, JsValue> {
|
||||
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"))
|
||||
}
|
||||
|
|
@ -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<WebShell, JsValue> {
|
||||
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::<HtmlCanvasElement>()
|
||||
.map_err(|_| JsValue::from_str("mount: target element is not <canvas>"))?;
|
||||
|
||||
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<WebShell, JsValue> {
|
||||
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::<HtmlCanvasElement>()
|
||||
.map_err(|_| JsValue::from_str("mount: target element is not <canvas>"))?;
|
||||
|
||||
Ok(WebShell {})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue