From 6b9fef8c45a8f6d8edbd9e4aa268bcacc28728c4 Mon Sep 17 00:00:00 2001 From: Dean Srebnik Date: Sun, 19 Apr 2026 00:22:24 -0400 Subject: [PATCH] feat(ext/canvas): implement more context2d methods --- Cargo.lock | 6 +- Cargo.toml | 2 +- crates/cli/src/check.rs | 2 + crates/cli/src/error.rs | 1 + crates/cli/src/lint.rs | 2 + crates/core/src/error.rs | 3 +- crates/runtime/src/ext/canvas/context2d.rs | 795 +++++++++--- crates/runtime/src/ext/canvas/fill_style.rs | 76 +- crates/runtime/src/ext/canvas/image.ts | 11 + crates/runtime/src/ext/canvas/mod.rs | 921 ++++++++++++- crates/runtime/src/ext/canvas/mod.ts | 1147 +++++++++++++++-- .../runtime/src/ext/canvas/renderer/render.rs | 467 +++++-- .../runtime/src/ext/canvas/renderer/shader.rs | 59 +- crates/runtime/src/ext/canvas/text.rs | 10 +- crates/runtime/src/ext/command.rs | 3 +- crates/runtime/src/ext/fs.rs | 16 +- crates/runtime/src/ext/web/mod.rs | 19 +- examples/canvas_advanced.ts | 173 ++- namespace/mod.ts | 60 +- types/global.d.ts | 113 +- 20 files changed, 3259 insertions(+), 627 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a8d3bf26..6809d3e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,7 +74,7 @@ dependencies = [ [[package]] name = "andromeda" -version = "0.1.4" +version = "0.1.5" dependencies = [ "andromeda-core", "andromeda-runtime", @@ -126,7 +126,7 @@ dependencies = [ [[package]] name = "andromeda-core" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "anymap", @@ -150,7 +150,7 @@ dependencies = [ [[package]] name = "andromeda-runtime" -version = "0.1.4" +version = "0.1.5" dependencies = [ "andromeda-core", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 4b0267f8..b8d5a10f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ authors = ["the Andromeda team"] edition = "2024" license = "Mozilla Public License 2.0" repository = "https://github.com/tryandromeda/andromeda" -version = "0.1.4" +version = "0.1.5" [workspace.dependencies] andromeda-core = { path = "crates/core" } diff --git a/crates/cli/src/check.rs b/crates/cli/src/check.rs index 0feb5252..655fc944 100644 --- a/crates/cli/src/check.rs +++ b/crates/cli/src/check.rs @@ -1,6 +1,8 @@ // This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. // If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/. +#![allow(unused_assignments)] + use crate::config::AndromedaConfig; use crate::error::{CliError, CliResult}; use crate::helper::find_formattable_files; diff --git a/crates/cli/src/error.rs b/crates/cli/src/error.rs index a8d18cd5..8f9200cc 100644 --- a/crates/cli/src/error.rs +++ b/crates/cli/src/error.rs @@ -3,6 +3,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. #![allow(clippy::result_large_err)] +#![allow(unused_assignments)] use andromeda_core::RuntimeError; use miette::{Diagnostic, NamedSource, SourceSpan}; diff --git a/crates/cli/src/lint.rs b/crates/cli/src/lint.rs index 043cedfa..7aa2174e 100644 --- a/crates/cli/src/lint.rs +++ b/crates/cli/src/lint.rs @@ -56,6 +56,8 @@ //! max_warnings = 10 //! ``` +#![allow(unused_assignments)] + use crate::config::{AndromedaConfig, ConfigManager, LintConfig}; use crate::error::{CliError, CliResult}; use console::Style; diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index cc8d3b5e..d766fcda 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -2,6 +2,8 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +#![allow(unused_assignments)] + use miette::{self as oxc_miette, Diagnostic, NamedSource, SourceSpan}; use owo_colors::OwoColorize; use oxc_diagnostics::OxcDiagnostic; @@ -9,7 +11,6 @@ use std::fmt; /// Comprehensive error type for Andromeda runtime operations. #[derive(Diagnostic, Debug, Clone)] -#[allow(unused_assignments)] pub enum RuntimeError { /// File system operation errors #[diagnostic( diff --git a/crates/runtime/src/ext/canvas/context2d.rs b/crates/runtime/src/ext/canvas/context2d.rs index 20eb1c4a..1f27a11d 100644 --- a/crates/runtime/src/ext/canvas/context2d.rs +++ b/crates/runtime/src/ext/canvas/context2d.rs @@ -15,6 +15,38 @@ use nova_vm::{ engine::{Bindable, GcScope}, }; +/// Yield `(start, end)` index pairs over `current_path` so each subpath +/// can be rendered independently. +/// +/// `subpath_starts` holds the `current_path` index at which each `moveTo` +/// began a new subpath. The first subpath is implicit when the path opens +/// without an explicit `moveTo` (e.g. `beginPath(); arc(...); fill();`). +fn subpath_ranges(current_path: &[Point], subpath_starts: &[usize]) -> Vec<(usize, usize)> { + if current_path.is_empty() { + return Vec::new(); + } + + let mut starts: Vec = Vec::with_capacity(subpath_starts.len() + 1); + if subpath_starts.first().copied() != Some(0) { + starts.push(0); + } + for &s in subpath_starts { + if s <= current_path.len() && starts.last().copied() != Some(s) { + starts.push(s); + } + } + + let mut ranges = Vec::with_capacity(starts.len()); + for i in 0..starts.len() { + let start = starts[i]; + let end = starts.get(i + 1).copied().unwrap_or(current_path.len()); + if end > start { + ranges.push((start, end)); + } + } + ranges +} + /// A command to be executed on the canvas #[derive(Clone)] #[allow(dead_code)] @@ -37,6 +69,7 @@ pub enum CanvasCommand<'gc> { radius: Number<'gc>, start_angle: Number<'gc>, end_angle: Number<'gc>, + counter_clockwise: bool, }, ArcTo { x1: Number<'gc>, @@ -205,6 +238,7 @@ pub fn internal_canvas_arc<'gc>( .to_number(agent, gc.reborrow()) .unbind() .unwrap(); + let counter_clockwise = args.get(6).is_true(); let host_data = agent .get_host_data() @@ -229,6 +263,7 @@ pub fn internal_canvas_arc<'gc>( radius_f64, start_angle_f64, end_angle_f64, + counter_clockwise, ); data.commands.push(CanvasCommand::Arc { @@ -237,6 +272,7 @@ pub fn internal_canvas_arc<'gc>( radius, start_angle, end_angle, + counter_clockwise, }); Ok(Value::Undefined) @@ -312,6 +348,12 @@ pub fn internal_canvas_begin_path<'gc>( let mut storage = host_data.storage.borrow_mut(); let res: &mut CanvasResources = storage.get_mut().unwrap(); let mut data = res.canvases.get_mut(rid).unwrap(); + // Reset the in-flight path state. Without this, `data.current_path` + // accumulates across every shape ever drawn on the canvas and the next + // fill/stroke renders every point in the history as one giant polygon. + data.current_path.clear(); + data.subpath_starts.clear(); + data.path_started = false; data.commands.push(CanvasCommand::BeginPath); Ok(Value::Undefined) } @@ -514,6 +556,16 @@ pub fn internal_canvas_close_path<'gc>( let mut storage = host_data.storage.borrow_mut(); let res: &mut CanvasResources = storage.get_mut().unwrap(); let mut data = res.canvases.get_mut(rid).unwrap(); + + // Close the CURRENT subpath by appending a copy of its first point. + // The GPU render path reads data.current_path directly, so without this + // the closing edge of a stroked polygon is never drawn. + let subpath_start = data.subpath_starts.last().copied().unwrap_or(0); + if subpath_start < data.current_path.len() { + let first = data.current_path[subpath_start].clone(); + data.current_path.push(first); + } + data.commands.push(CanvasCommand::ClosePath); Ok(Value::Undefined) } @@ -641,7 +693,14 @@ pub fn internal_canvas_move_to<'gc>( let x_f64 = x.into_f64(agent); let y_f64 = y.into_f64(agent); - // Start a new subpath in the current path + // Start a new subpath in the current path. + // + // Canvas 2D paths can contain multiple disconnected subpaths. Record + // the index at which this subpath begins so `fill`/`stroke` can render + // each subpath independently instead of drawing one giant polygon that + // zig-zags between subpath endpoints. + let start_idx = data.current_path.len(); + data.subpath_starts.push(start_idx); data.current_path .push(crate::ext::canvas::renderer::Point { x: x_f64, y: y_f64 }); data.path_started = true; @@ -717,26 +776,29 @@ pub fn internal_canvas_fill<'gc>( if let Some(mut renderer) = res.renderers.get_mut(rid) { let data = res.canvases.get(rid).unwrap(); - // data; - - if data.current_path.len() >= 3 { - renderer.render_polygon( - data.current_path.clone(), - &RenderState { - fill_style: data.fill_style, - global_alpha: data.global_alpha, - transform: data.transform, - line_cap: data.line_cap, - line_join: data.line_join, - miter_limit: data.miter_limit, - shadow_blur: data.shadow_blur, - shadow_color: data.shadow_color, - shadow_offset_x: data.shadow_offset_x, - shadow_offset_y: data.shadow_offset_y, - composite_operation: data.composite_operation, - clip_path: None, - }, - ); + let state = RenderState { + fill_style: data.fill_style, + global_alpha: data.global_alpha, + transform: data.transform, + line_cap: data.line_cap, + line_join: data.line_join, + miter_limit: data.miter_limit, + shadow_blur: data.shadow_blur, + shadow_color: data.shadow_color, + shadow_offset_x: data.shadow_offset_x, + shadow_offset_y: data.shadow_offset_y, + composite_operation: data.composite_operation, + clip_path: None, + }; + + // Render each subpath as its own polygon so compound paths (e.g. + // two arcs separated by moveTo) don't collapse into one polygon + // with a stray edge joining the subpaths. + for (start, end) in subpath_ranges(&data.current_path, &data.subpath_starts) { + if end - start >= 3 { + let subpath = data.current_path[start..end].to_vec(); + renderer.render_polygon(subpath, &state); + } } } else { // Fallback to command storage if no renderer @@ -768,28 +830,42 @@ pub fn internal_canvas_stroke<'gc>( if let Some(mut renderer) = res.renderers.get_mut(rid) { let data = res.canvases.get(rid).unwrap(); - if data.current_path.len() >= 2 { - // Convert path to stroke polygon using line width - let stroke_path = generate_stroke_path(&data.current_path, data.line_width); - - // Render the stroke as a polygon using the GPU renderer - renderer.render_polygon( - stroke_path, - &RenderState { - fill_style: data.stroke_style, - global_alpha: data.global_alpha, - transform: data.transform, - line_cap: data.line_cap, - line_join: data.line_join, - miter_limit: data.miter_limit, - shadow_blur: data.shadow_blur, - shadow_color: data.shadow_color, - shadow_offset_x: data.shadow_offset_x, - shadow_offset_y: data.shadow_offset_y, - composite_operation: data.composite_operation, - clip_path: None, - }, - ); + let state = RenderState { + fill_style: data.stroke_style, + global_alpha: data.global_alpha, + transform: data.transform, + line_cap: data.line_cap, + line_join: data.line_join, + miter_limit: data.miter_limit, + shadow_blur: data.shadow_blur, + shadow_color: data.shadow_color, + shadow_offset_x: data.shadow_offset_x, + shadow_offset_y: data.shadow_offset_y, + composite_operation: data.composite_operation, + clip_path: None, + }; + + // Stroke each subpath independently so the renderer doesn't draw a + // phantom segment joining the end of subpath A to the start of + // subpath B. Each subpath's stroke is emitted as a triangle list + // (6 verts per visible segment) so fan triangulation doesn't + // collapse multi-segment strokes into a fan. + for (start, end) in subpath_ranges(&data.current_path, &data.subpath_starts) { + if end - start >= 2 { + let subpath = &data.current_path[start..end]; + let stroke_triangles = generate_stroke_path( + subpath, + data.line_width, + &data.line_dash, + data.line_dash_offset, + data.line_cap, + data.line_join, + data.miter_limit, + ); + if !stroke_triangles.is_empty() { + renderer.render_triangles(stroke_triangles, &state); + } + } } } else { // Fallback to command storage if no renderer @@ -800,7 +876,7 @@ pub fn internal_canvas_stroke<'gc>( Ok(Value::Undefined) } -/// Internal op to create a rectangle path on a canvas by Rid +/// Internal op to create a rectangle path on a canvas by Rid pub fn internal_canvas_rect<'gc>( agent: &mut Agent, _this: Value, @@ -844,7 +920,16 @@ pub fn internal_canvas_rect<'gc>( let width_f64 = width.into_f64(agent); let height_f64 = height.into_f64(agent); - // Add rectangle to current path as four corners + // Per the Canvas 2D spec, `rect` creates a new implicit subpath. + // Record the subpath start so a preceding moveTo/lineTo block doesn't + // get zig-zag-joined to these four corners under fan triangulation. + let subpath_start = data.current_path.len(); + data.subpath_starts.push(subpath_start); + + // Add rectangle to current path as four corners, plus a fifth point + // duplicating the first. Per the HTML Canvas 2D spec, `rect` emits a + // CLOSED subpath — the duplicate closes it so `stroke()` walks all four + // edges instead of stopping at the third segment. data.current_path .push(crate::ext::canvas::renderer::Point { x: x_f64, y: y_f64 }); data.current_path.push(crate::ext::canvas::renderer::Point { @@ -859,6 +944,8 @@ pub fn internal_canvas_rect<'gc>( x: x_f64, y: y_f64 + height_f64, }); + data.current_path + .push(crate::ext::canvas::renderer::Point { x: x_f64, y: y_f64 }); data.path_started = true; // Also store as command for fallback @@ -912,7 +999,16 @@ pub fn internal_canvas_set_stroke_style<'gc>( let rid = Rid::from_index(rid_val); let style = args.get(1); - // Convert style to string first to avoid borrowing conflicts + // If the style is a number it's a gradient or pattern rid; if it's a + // string it's a CSS color. Resolve each path separately so that + // `ctx.strokeStyle = gradient` actually propagates to the canvas's + // `stroke_style` field (matches fillStyle's behavior). + let is_number = style.is_number(); + let fill_rid = if is_number { + style.to_uint32(agent, gc.reborrow()).unbind().ok() + } else { + None + }; let style_string = if style.is_string() { Some(style.to_string(agent, gc.reborrow()).unbind().unwrap()) } else { @@ -925,19 +1021,23 @@ pub fn internal_canvas_set_stroke_style<'gc>( .unwrap(); let mut storage = host_data.storage.borrow_mut(); let res: &mut CanvasResources = storage.get_mut().unwrap(); - let mut data = res.canvases.get_mut(rid).unwrap(); - if let Some(style_str_obj) = style_string { + let resolved: Option = if let Some(rid_u32) = fill_rid { + res.fill_styles.get(Rid::from_index(rid_u32)) + } else if let Some(style_str_obj) = &style_string { let style_str = style_str_obj .as_str(agent) .expect("String is not valid UTF-8"); - if let Ok(parsed_style) = - FillStyle::from_css_color(style_str).map_err(|_| "Invalid color format") - { - data.stroke_style = parsed_style.clone(); - data.commands - .push(CanvasCommand::SetStrokeStyle(parsed_style)); - } + FillStyle::from_css_color(style_str).ok() + } else { + None + }; + + if let Some(parsed_style) = resolved { + let mut data = res.canvases.get_mut(rid).unwrap(); + data.stroke_style = parsed_style.clone(); + data.commands + .push(CanvasCommand::SetStrokeStyle(parsed_style)); } Ok(Value::Undefined) } @@ -1134,11 +1234,46 @@ pub fn internal_canvas_round_rect<'gc>( .to_number(agent, gc.reborrow()) .unbind() .unwrap(); - let radius = args + // Read four per-corner radii (TL, TR, BR, BL) from args 5..=8. + // Missing args default to 0, preserving a sharp corner, so callers + // passing only the old 6-arg form still produce a plain rectangle. + let tl_num = args .get(5) .to_number(agent, gc.reborrow()) .unbind() - .unwrap(); + .unwrap_or_else(|_| Number::from(0)); + let tr_num = args + .get(6) + .to_number(agent, gc.reborrow()) + .unbind() + .unwrap_or_else(|_| Number::from(0)); + let br_num = args + .get(7) + .to_number(agent, gc.reborrow()) + .unbind() + .unwrap_or_else(|_| Number::from(0)); + let bl_num = args + .get(8) + .to_number(agent, gc.reborrow()) + .unbind() + .unwrap_or_else(|_| Number::from(0)); + let tl_f64 = tl_num.into_f64(agent); + let tr_f64 = tr_num.into_f64(agent); + let br_f64 = br_num.into_f64(agent); + let bl_f64 = bl_num.into_f64(agent); + + // Pick the largest corner for the fallback replay Command (the + // command-replay path doesn't yet carry per-corner radii; this is + // a best-effort for the no-GPU fallback only). + let radius_for_command = if tl_f64 >= tr_f64 && tl_f64 >= br_f64 && tl_f64 >= bl_f64 { + tl_num + } else if tr_f64 >= br_f64 && tr_f64 >= bl_f64 { + tr_num + } else if br_f64 >= bl_f64 { + br_num + } else { + bl_num + }; let host_data = agent .get_host_data() @@ -1148,21 +1283,18 @@ pub fn internal_canvas_round_rect<'gc>( let res: &mut CanvasResources = storage.get_mut().unwrap(); let mut data = res.canvases.get_mut(rid).unwrap(); - // Convert Nova VM Numbers to f64 for renderer let x_f64 = x.into_f64(agent); let y_f64 = y.into_f64(agent); let width_f64 = width.into_f64(agent); let height_f64 = height.into_f64(agent); - let radius_f64 = radius.into_f64(agent); - // Tessellate rounded rectangle to current path tessellate_rounded_rect_to_path( &mut data.current_path, x_f64, y_f64, width_f64, height_f64, - radius_f64, + [tl_f64, tr_f64, br_f64, bl_f64], ); data.commands.push(CanvasCommand::RoundRect { @@ -1170,7 +1302,7 @@ pub fn internal_canvas_round_rect<'gc>( y, width, height, - radius, + radius: radius_for_command, }); Ok(Value::Undefined) @@ -1242,7 +1374,13 @@ pub fn internal_canvas_restore<'gc>( let res: &mut CanvasResources = storage.get_mut().unwrap(); let mut data = res.canvases.get_mut(rid).unwrap(); - // Restore state from stack if available + // Restore state from stack if available. The spec requires + // restore() to revert EVERY field that save() captured — previously + // this path only restored 8 of 19 fields, so shadow state, line + // cap/join/miter, and all text attributes leaked across save/restore + // boundaries. The most visible symptom: a fill drawn with shadows + // inside a save()/restore() block still left a shadow trail behind + // subsequent fills that expected no shadow. if let Some(saved_state) = data.state_stack.pop() { data.fill_style = saved_state.fill_style; data.stroke_style = saved_state.stroke_style; @@ -1251,7 +1389,18 @@ pub fn internal_canvas_restore<'gc>( data.transform = saved_state.transform; data.line_dash = saved_state.line_dash; data.line_dash_offset = saved_state.line_dash_offset; + data.line_cap = saved_state.line_cap; + data.line_join = saved_state.line_join; + data.miter_limit = saved_state.miter_limit; + data.shadow_blur = saved_state.shadow_blur; + data.shadow_color = saved_state.shadow_color; + data.shadow_offset_x = saved_state.shadow_offset_x; + data.shadow_offset_y = saved_state.shadow_offset_y; data.composite_operation = saved_state.composite_operation; + data.font = saved_state.font; + data.text_align = saved_state.text_align; + data.text_baseline = saved_state.text_baseline; + data.direction = saved_state.direction; } // Add restore command to command list @@ -1397,7 +1546,7 @@ pub fn process_all_commands<'gc>( y_f64, width_f64, height_f64, - radius_f64, + [radius_f64, radius_f64, radius_f64, radius_f64], ); } CanvasCommand::BeginPath => { @@ -1732,18 +1881,59 @@ fn tessellate_ellipse_to_path( } /// Tessellate a rounded rectangle into line segments and add to path +/// Tessellate a rounded rectangle with four independent corner radii +/// per the HTML Canvas spec (`roundRect(x, y, w, h, [tl, tr, br, bl])`). +/// +/// Each radius is clamped individually to `min(width/2, height/2)`. A +/// corner with radius 0 is emitted as a sharp point, preserving the +/// square-corner case inline. fn tessellate_rounded_rect_to_path( path: &mut Vec, x: f64, y: f64, width: f64, height: f64, - radius: f64, + radii: [f64; 4], ) { - let radius = radius.min(width / 2.0).min(height / 2.0).max(0.0); - - if radius <= 0.0 { - // Simple rectangle + // Coerce negatives / NaN to 0 before applying the spec scaling. + let sanitize = |r: f64| if r.is_finite() && r > 0.0 { r } else { 0.0 }; + let r_tl = sanitize(radii[0]); + let r_tr = sanitize(radii[1]); + let r_br = sanitize(radii[2]); + let r_bl = sanitize(radii[3]); + + // HTML spec scaling: if adjacent corner radii along any side would + // overlap, scale ALL four radii by the smallest factor needed so the + // overlap just disappears. This is looser than per-corner clamping + // to min(w/2, h/2) — a tall-and-thin rectangle can still have a + // large corner radius on its long edges as long as the short edge's + // neighbor is small. + // + // top = tl + tr <= width + // bottom = br + bl <= width + // left = tl + bl <= height + // right = tr + br <= height + let top = r_tl + r_tr; + let right = r_tr + r_br; + let bottom = r_br + r_bl; + let left = r_bl + r_tl; + let mut scale = 1.0f64; + let check = |sum: f64, limit: f64, scale: &mut f64| { + if sum > 0.0 && limit > 0.0 && limit / sum < *scale { + *scale = limit / sum; + } + }; + check(top, width, &mut scale); + check(bottom, width, &mut scale); + check(left, height, &mut scale); + check(right, height, &mut scale); + let tl = r_tl * scale; + let tr = r_tr * scale; + let br = r_br * scale; + let bl = r_bl * scale; + + if tl <= 0.0 && tr <= 0.0 && br <= 0.0 && bl <= 0.0 { + // Fast path: plain rectangle. path.push(crate::ext::canvas::renderer::Point { x, y }); path.push(crate::ext::canvas::renderer::Point { x: x + width, y }); path.push(crate::ext::canvas::renderer::Point { @@ -1751,82 +1941,82 @@ fn tessellate_rounded_rect_to_path( y: y + height, }); path.push(crate::ext::canvas::renderer::Point { x, y: y + height }); - path.push(crate::ext::canvas::renderer::Point { x, y }); // Close + path.push(crate::ext::canvas::renderer::Point { x, y }); // close return; } - const CORNER_SEGMENTS: usize = 8; // Segments per corner + const CORNER_SEGMENTS: usize = 8; + + // Emit a quarter-circle centered at (cx, cy) from `start_angle` to + // `start_angle + π/2`. Degenerate to a single point when r == 0. + let push_corner = |path: &mut Vec, + cx: f64, + cy: f64, + r: f64, + start_angle: f64| { + if r <= 0.0 { + path.push(crate::ext::canvas::renderer::Point { x: cx, y: cy }); + return; + } + for i in 0..=CORNER_SEGMENTS { + let a = start_angle + i as f64 * std::f64::consts::FRAC_PI_2 / CORNER_SEGMENTS as f64; + path.push(crate::ext::canvas::renderer::Point { + x: cx + r * a.cos(), + y: cy + r * a.sin(), + }); + } + }; - // Top-left corner (start at right edge of arc) - for i in 0..=CORNER_SEGMENTS { - let angle = - std::f64::consts::PI + i as f64 * std::f64::consts::PI / (2.0 * CORNER_SEGMENTS as f64); - let corner_x = x + radius + radius * angle.cos(); - let corner_y = y + radius + radius * angle.sin(); - path.push(crate::ext::canvas::renderer::Point { - x: corner_x, - y: corner_y, - }); - } + // Top-left corner: arc from π → 3π/2, centered at (x+tl, y+tl). + push_corner(path, x + tl, y + tl, tl, std::f64::consts::PI); - // Top edge + // Top edge. path.push(crate::ext::canvas::renderer::Point { - x: x + width - radius, + x: x + width - tr, y, }); - // Top-right corner - for i in 0..=CORNER_SEGMENTS { - let angle = -std::f64::consts::PI / 2.0 - + i as f64 * std::f64::consts::PI / (2.0 * CORNER_SEGMENTS as f64); - let corner_x = x + width - radius + radius * angle.cos(); - let corner_y = y + radius + radius * angle.sin(); - path.push(crate::ext::canvas::renderer::Point { - x: corner_x, - y: corner_y, - }); - } + // Top-right corner: arc from 3π/2 → 2π, centered at (x+w-tr, y+tr). + push_corner( + path, + x + width - tr, + y + tr, + tr, + -std::f64::consts::FRAC_PI_2, + ); - // Right edge + // Right edge. path.push(crate::ext::canvas::renderer::Point { x: x + width, - y: y + height - radius, + y: y + height - br, }); - // Bottom-right corner - for i in 0..=CORNER_SEGMENTS { - let angle = 0.0 + i as f64 * std::f64::consts::PI / (2.0 * CORNER_SEGMENTS as f64); - let corner_x = x + width - radius + radius * angle.cos(); - let corner_y = y + height - radius + radius * angle.sin(); - path.push(crate::ext::canvas::renderer::Point { - x: corner_x, - y: corner_y, - }); - } + // Bottom-right corner: arc from 0 → π/2, centered at (x+w-br, y+h-br). + push_corner(path, x + width - br, y + height - br, br, 0.0); - // Bottom edge + // Bottom edge. path.push(crate::ext::canvas::renderer::Point { - x: x + radius, + x: x + bl, y: y + height, }); - // Bottom-left corner - for i in 0..=CORNER_SEGMENTS { - let angle = std::f64::consts::PI / 2.0 - + i as f64 * std::f64::consts::PI / (2.0 * CORNER_SEGMENTS as f64); - let corner_x = x + radius + radius * angle.cos(); - let corner_y = y + height - radius + radius * angle.sin(); - path.push(crate::ext::canvas::renderer::Point { - x: corner_x, - y: corner_y, - }); - } + // Bottom-left corner: arc from π/2 → π, centered at (x+bl, y+h-bl). + push_corner( + path, + x + bl, + y + height - bl, + bl, + std::f64::consts::FRAC_PI_2, + ); - // Left edge and close - path.push(crate::ext::canvas::renderer::Point { x, y: y + radius }); + // Left edge + close. + path.push(crate::ext::canvas::renderer::Point { x, y: y + tl }); } -/// Helper function to tessellate arc and add to path (for existing arc function) +/// Helper function to tessellate arc and add to path. Delegates to the +/// ellipse tessellator with equal x/y radii and zero rotation; the +/// `counter_clockwise` flag flows through so `arc()` honors the sweep +/// direction the same way `ellipse()` does. fn tessellate_arc_to_path( path: &mut Vec, x: f64, @@ -1834,6 +2024,7 @@ fn tessellate_arc_to_path( radius: f64, start_angle: f64, end_angle: f64, + counter_clockwise: bool, ) { tessellate_ellipse_to_path( path, @@ -1844,7 +2035,7 @@ fn tessellate_arc_to_path( 0.0, start_angle, end_angle, - false, + counter_clockwise, ); } @@ -1877,50 +2068,348 @@ fn tessellate_bezier_curve_to_path( } /// Generate stroke path from current path with line width +/// Turn a polyline into the triangle list for its stroke, honoring the +/// current line width, cap style, join style, miter limit, and line +/// dash pattern. +pub fn generate_stroke_path_public( + path: &[crate::ext::canvas::renderer::Point], + line_width: f64, + dash: &[f64], + dash_offset: f64, + line_cap: LineCap, + line_join: LineJoin, + miter_limit: f64, +) -> Vec { + generate_stroke_path( + path, + line_width, + dash, + dash_offset, + line_cap, + line_join, + miter_limit, + ) +} + fn generate_stroke_path( path: &[crate::ext::canvas::renderer::Point], line_width: f64, + dash: &[f64], + dash_offset: f64, + line_cap: LineCap, + line_join: LineJoin, + miter_limit: f64, ) -> Vec { if path.len() < 2 { return Vec::new(); } - let half_width = line_width / 2.0; - let mut stroke_vertices = Vec::new(); + let mut out: Vec = Vec::new(); + + // Corners of a single segment's rectangle: (outer_from, inner_from, outer_to, inner_to). + type SegmentCorners = ((f64, f64), (f64, f64), (f64, f64), (f64, f64)); + + let pt = |x: f64, y: f64| crate::ext::canvas::renderer::Point { x, y }; + let push_segment = |out: &mut Vec, + ax: f64, + ay: f64, + bx: f64, + by: f64| + -> Option { + let dx = bx - ax; + let dy = by - ay; + let len = (dx * dx + dy * dy).sqrt(); + if len <= 0.0 { + return None; + } + let nx = -dy / len * half_width; + let ny = dx / len * half_width; + let tl = (ax + nx, ay + ny); + let bl = (ax - nx, ay - ny); + let tr = (bx + nx, by + ny); + let br = (bx - nx, by - ny); + out.push(pt(tl.0, tl.1)); + out.push(pt(bl.0, bl.1)); + out.push(pt(tr.0, tr.1)); + out.push(pt(bl.0, bl.1)); + out.push(pt(br.0, br.1)); + out.push(pt(tr.0, tr.1)); + Some((tl, bl, tr, br)) + }; - // Simple stroke generation - create parallel lines on both sides - for i in 0..path.len() - 1 { - let a = &path[i]; - let b = &path[i + 1]; + // Fan a disk (or half-disk) around `center` between two unit + // directions, used by round caps and round joins. + let round_fan = |out: &mut Vec, + cx: f64, + cy: f64, + start_angle: f64, + sweep: f64| { + // Segment the sweep so each chord is at most ~a few pixels on a + // unit-radius curve. 12 segments for a half turn reads as smooth + // under 4× MSAA without ballooning vertex counts. + let n = ((sweep.abs() / std::f64::consts::PI) * 12.0) + .ceil() + .max(3.0) as usize; + let mut prev = ( + cx + start_angle.cos() * half_width, + cy + start_angle.sin() * half_width, + ); + for i in 1..=n { + let t = i as f64 / n as f64; + let a = start_angle + sweep * t; + let nxt = (cx + a.cos() * half_width, cy + a.sin() * half_width); + out.push(pt(cx, cy)); + out.push(pt(prev.0, prev.1)); + out.push(pt(nxt.0, nxt.1)); + prev = nxt; + } + }; + + // Butt: no extra geometry. Round: half-disk around `center` + // on the `outward` side. Square: rectangle extending half_width in + // the `outward` direction. + // + // `outward_angle` points away from the segment (opposite of the + // segment direction for a start cap; along the segment direction + // for an end cap). + let draw_cap = |out: &mut Vec, + cx: f64, + cy: f64, + outward_dx: f64, + outward_dy: f64| { + match line_cap { + LineCap::Butt => {} + LineCap::Round => { + let base_angle = outward_dy.atan2(outward_dx); + // Half-disk from +90° to -90° around the outward axis. + round_fan( + out, + cx, + cy, + base_angle - std::f64::consts::FRAC_PI_2, + std::f64::consts::PI, + ); + } + LineCap::Square => { + let px = -outward_dy * half_width; + let py = outward_dx * half_width; + let ex = outward_dx * half_width; + let ey = outward_dy * half_width; + let a = (cx + px, cy + py); + let b = (cx - px, cy - py); + let c = (cx + px + ex, cy + py + ey); + let d = (cx - px + ex, cy - py + ey); + out.push(pt(a.0, a.1)); + out.push(pt(b.0, b.1)); + out.push(pt(c.0, c.1)); + out.push(pt(b.0, b.1)); + out.push(pt(d.0, d.1)); + out.push(pt(c.0, c.1)); + } + } + }; - // Calculate perpendicular vector + // Fill the corner between two adjacent segments sharing a vertex. + // `(ux1, uy1)` is the direction of the incoming segment; `(ux2, + // uy2)` is the direction of the outgoing segment. Both are unit. + let draw_join = |out: &mut Vec, + cx: f64, + cy: f64, + ux1: f64, + uy1: f64, + ux2: f64, + uy2: f64| { + // Cross product tells us which side the corner sticks out on. + let cross = ux1 * uy2 - uy1 * ux2; + if cross.abs() < 1e-9 { + return; // collinear — no gap to fill + } + // Outer-edge normals. + let n1x = -uy1 * half_width; + let n1y = ux1 * half_width; + let n2x = -uy2 * half_width; + let n2y = ux2 * half_width; + // Pick the outside side based on cross sign. + let (out_n1x, out_n1y, out_n2x, out_n2y) = if cross > 0.0 { + (-n1x, -n1y, -n2x, -n2y) + } else { + (n1x, n1y, n2x, n2y) + }; + let outer_from = (cx + out_n1x, cy + out_n1y); + let outer_to = (cx + out_n2x, cy + out_n2y); + + match line_join { + LineJoin::Bevel => { + out.push(pt(cx, cy)); + out.push(pt(outer_from.0, outer_from.1)); + out.push(pt(outer_to.0, outer_to.1)); + } + LineJoin::Round => { + let a1 = out_n1y.atan2(out_n1x); + let a2 = out_n2y.atan2(out_n2x); + let mut sweep = a2 - a1; + // Take the shortest path around the outside. + while sweep > std::f64::consts::PI { + sweep -= 2.0 * std::f64::consts::PI; + } + while sweep < -std::f64::consts::PI { + sweep += 2.0 * std::f64::consts::PI; + } + // Flip so we sweep on the OUTSIDE of the corner, not + // across the inside. + if (cross > 0.0 && sweep > 0.0) || (cross < 0.0 && sweep < 0.0) { + sweep = if sweep > 0.0 { + sweep - 2.0 * std::f64::consts::PI + } else { + sweep + 2.0 * std::f64::consts::PI + }; + } + let _ = a2; + round_fan(out, cx, cy, a1, sweep); + } + LineJoin::Miter => { + // Compute the miter apex by intersecting the two outer + // edges. If it exceeds miter_limit * half_width from the + // vertex, fall back to bevel per the HTML spec. + let denom = ux1 * uy2 - uy1 * ux2; + if denom.abs() < 1e-9 { + return; + } + // Edge 1: start = outer_from, dir = (ux1, uy1) + // Edge 2: start = outer_to, dir = (ux2, uy2) + // Solve outer_from + t1*(ux1, uy1) = outer_to + t2*(ux2, uy2) + let dx = outer_to.0 - outer_from.0; + let dy = outer_to.1 - outer_from.1; + let t1 = (dx * uy2 - dy * ux2) / denom; + let miter_x = outer_from.0 + ux1 * t1; + let miter_y = outer_from.1 + uy1 * t1; + let mdx = miter_x - cx; + let mdy = miter_y - cy; + let miter_len = (mdx * mdx + mdy * mdy).sqrt(); + if miter_len > miter_limit * half_width { + // Beyond the miter limit — fall back to bevel. + out.push(pt(cx, cy)); + out.push(pt(outer_from.0, outer_from.1)); + out.push(pt(outer_to.0, outer_to.1)); + } else { + // Two triangles fan from the center to the apex. + out.push(pt(cx, cy)); + out.push(pt(outer_from.0, outer_from.1)); + out.push(pt(miter_x, miter_y)); + out.push(pt(cx, cy)); + out.push(pt(miter_x, miter_y)); + out.push(pt(outer_to.0, outer_to.1)); + } + } + } + }; + + let dash_normalized: Vec = if dash.is_empty() { + Vec::new() + } else if dash.len() % 2 == 1 { + let mut v = Vec::with_capacity(dash.len() * 2); + v.extend_from_slice(dash); + v.extend_from_slice(dash); + v + } else { + dash.to_vec() + }; + + // Helper: segment direction as a unit vector. + let seg_dir = |a: &crate::ext::canvas::renderer::Point, + b: &crate::ext::canvas::renderer::Point| + -> Option<(f64, f64)> { let dx = b.x - a.x; let dy = b.y - a.y; - let length = (dx * dx + dy * dy).sqrt(); + let len = (dx * dx + dy * dy).sqrt(); + if len <= 0.0 { + None + } else { + Some((dx / len, dy / len)) + } + }; + + if dash_normalized.is_empty() { + // Emit segment quads + joins at interior vertices + caps at + // the two ends. Skip zero-length segments but preserve vertex + // indexing for joins. + let mut last_dir: Option<(f64, f64)> = None; + for i in 0..path.len() - 1 { + let a = &path[i]; + let b = &path[i + 1]; + if push_segment(&mut out, a.x, a.y, b.x, b.y).is_some() { + let dir = seg_dir(a, b).unwrap(); + if let Some((pux, puy)) = last_dir { + draw_join(&mut out, a.x, a.y, pux, puy, dir.0, dir.1); + } else { + // First segment: start cap pointing opposite the + // direction of travel. + draw_cap(&mut out, a.x, a.y, -dir.0, -dir.1); + } + last_dir = Some(dir); + } + } + // End cap on the final segment's endpoint. + if let Some((ux, uy)) = last_dir { + let last = &path[path.len() - 1]; + draw_cap(&mut out, last.x, last.y, ux, uy); + } + return out; + } - if length > 0.0 { - let nx = -dy / length * half_width; // Perpendicular x - let ny = dx / length * half_width; // Perpendicular y + let total_dash: f64 = dash_normalized.iter().sum(); + if total_dash <= 0.0 { + return out; + } + let phase = dash_offset.rem_euclid(total_dash); + let mut dash_idx = 0usize; + let mut remaining_in_slot = dash_normalized[0]; + let mut walked = 0.0; + while walked + remaining_in_slot <= phase { + walked += remaining_in_slot; + dash_idx = (dash_idx + 1) % dash_normalized.len(); + remaining_in_slot = dash_normalized[dash_idx]; + } + remaining_in_slot -= phase - walked; + let mut pen_on = dash_idx.is_multiple_of(2); - // Add vertices for the stroke quad - stroke_vertices.push(crate::ext::canvas::renderer::Point { - x: a.x + nx, - y: a.y + ny, - }); - stroke_vertices.push(crate::ext::canvas::renderer::Point { - x: a.x - nx, - y: a.y - ny, - }); - stroke_vertices.push(crate::ext::canvas::renderer::Point { - x: b.x + nx, - y: b.y + ny, - }); - stroke_vertices.push(crate::ext::canvas::renderer::Point { - x: b.x - nx, - y: b.y - ny, - }); + for i in 0..path.len() - 1 { + let a = &path[i]; + let b = &path[i + 1]; + let Some((ux, uy)) = seg_dir(a, b) else { + continue; + }; + let seg_len = { + let dx = b.x - a.x; + let dy = b.y - a.y; + (dx * dx + dy * dy).sqrt() + }; + let mut consumed = 0.0f64; + let mut cur_x = a.x; + let mut cur_y = a.y; + while consumed < seg_len { + let take = remaining_in_slot.min(seg_len - consumed); + let nx = cur_x + ux * take; + let ny = cur_y + uy * take; + if pen_on { + push_segment(&mut out, cur_x, cur_y, nx, ny); + draw_cap(&mut out, cur_x, cur_y, -ux, -uy); + draw_cap(&mut out, nx, ny, ux, uy); + } + cur_x = nx; + cur_y = ny; + consumed += take; + remaining_in_slot -= take; + if remaining_in_slot <= f64::EPSILON { + dash_idx = (dash_idx + 1) % dash_normalized.len(); + remaining_in_slot = dash_normalized[dash_idx]; + pen_on = !pen_on; + } } } - stroke_vertices + let _ = line_join; + let _ = miter_limit; + + out } diff --git a/crates/runtime/src/ext/canvas/fill_style.rs b/crates/runtime/src/ext/canvas/fill_style.rs index 1d79f991..bf04fbf2 100644 --- a/crates/runtime/src/ext/canvas/fill_style.rs +++ b/crates/runtime/src/ext/canvas/fill_style.rs @@ -18,8 +18,9 @@ pub enum FillStyle { LinearGradient(LinearGradient), RadialGradient(RadialGradient), ConicGradient(ConicGradient), - /// Pattern with image resource ID and repetition mode + /// Pattern with image resource ID and repetition mode. Pattern { + pattern_rid: u32, image_rid: u32, repetition: PatternRepetition, }, @@ -100,10 +101,83 @@ impl FillStyle { return Self::parse_rgb_color(color_str); } + // Handle hsl() and hsla() colors + if color_str.starts_with("hsl(") || color_str.starts_with("hsla(") { + return Self::parse_hsl_color(color_str); + } + // Handle named colors Self::parse_named_color(color_str) } + fn parse_hsl_color(hsl: &str) -> Result { + let is_hsla = hsl.starts_with("hsla("); + let inner = if is_hsla { + hsl.trim_start_matches("hsla(").trim_end_matches(')') + } else { + hsl.trim_start_matches("hsl(").trim_end_matches(')') + }; + + let parts: Vec<&str> = inner.split(',').map(|s| s.trim()).collect(); + if (!is_hsla && parts.len() != 3) || (is_hsla && parts.len() != 4) { + return Err("Invalid hsl/hsla format".to_string()); + } + + let h_deg = parts[0].parse::().map_err(|_| "Invalid hue")?; + let s = parts[1] + .trim_end_matches('%') + .parse::() + .map_err(|_| "Invalid saturation")? + / 100.0; + let l = parts[2] + .trim_end_matches('%') + .parse::() + .map_err(|_| "Invalid lightness")? + / 100.0; + let a = if is_hsla { + parts[3].parse::().map_err(|_| "Invalid alpha value")? + } else { + 1.0 + }; + + // Standard HSL -> RGB conversion (CSS3). Hue wrapped to [0, 360). + let h = (h_deg.rem_euclid(360.0)) / 360.0; + let (r, g, b) = if s == 0.0 { + (l, l, l) + } else { + let q = if l < 0.5 { + l * (1.0 + s) + } else { + l + s - l * s + }; + let p = 2.0 * l - q; + let hue_to_rgb = |p: f32, q: f32, mut t: f32| -> f32 { + if t < 0.0 { + t += 1.0; + } + if t > 1.0 { + t -= 1.0; + } + if t < 1.0 / 6.0 { + p + (q - p) * 6.0 * t + } else if t < 0.5 { + q + } else if t < 2.0 / 3.0 { + p + (q - p) * (2.0 / 3.0 - t) * 6.0 + } else { + p + } + }; + ( + hue_to_rgb(p, q, h + 1.0 / 3.0), + hue_to_rgb(p, q, h), + hue_to_rgb(p, q, h - 1.0 / 3.0), + ) + }; + + Ok(FillStyle::Color { r, g, b, a }) + } + fn parse_hex_color(hex: &str) -> Result { let hex = hex.trim_start_matches('#'); diff --git a/crates/runtime/src/ext/canvas/image.ts b/crates/runtime/src/ext/canvas/image.ts index a72e454a..c0c2f845 100644 --- a/crates/runtime/src/ext/canvas/image.ts +++ b/crates/runtime/src/ext/canvas/image.ts @@ -40,6 +40,17 @@ class ImageBitmap { } return this[_height]; } + + /** + * Internal accessor for the backing image rid. Used by + * `CanvasRenderingContext2D.createPattern` to avoid reading the + * private `#rid` field via bracket syntax (which fails against true + * private fields and returned undefined, making `createPattern` a + * silent no-op). + */ + __getRid(): number { + return this.#rid; + } } /** diff --git a/crates/runtime/src/ext/canvas/mod.rs b/crates/runtime/src/ext/canvas/mod.rs index 0b38e3e3..cb6a4d06 100644 --- a/crates/runtime/src/ext/canvas/mod.rs +++ b/crates/runtime/src/ext/canvas/mod.rs @@ -30,10 +30,141 @@ use crate::ext::canvas::context2d::{ internal_canvas_stroke, }; use nova_vm::{ - ecmascript::{Agent, ArgumentsList, JsResult, SmallInteger, Value}, + ecmascript::{Agent, ArgumentsList, ExceptionType, JsResult, SmallInteger, Value}, engine::{Bindable, GcScope}, }; +/// Iterate `(start, end)` index pairs of subpaths within a flat point +/// array. Mirror of `subpath_ranges` in context2d but inlined here so +/// this module does not depend on a visibility-export of that helper. +fn current_subpath_ranges(points: &[renderer::Point], starts: &[usize]) -> Vec<(usize, usize)> { + if points.is_empty() { + return Vec::new(); + } + let mut out = Vec::with_capacity(starts.len() + 1); + let mut bounds: Vec = Vec::with_capacity(starts.len() + 2); + if starts.first().copied() != Some(0) { + bounds.push(0); + } + for &s in starts { + if s <= points.len() && bounds.last().copied() != Some(s) { + bounds.push(s); + } + } + bounds.push(points.len()); + for i in 0..bounds.len().saturating_sub(1) { + let s = bounds[i]; + let e = bounds[i + 1]; + if e > s { + out.push((s, e)); + } + } + out +} + +/// Ray-cast winding test: does the point (px, py) fall inside any +/// subpath of `points` under the given fill rule? +fn point_in_current_path( + points: &[renderer::Point], + starts: &[usize], + px: f64, + py: f64, + even_odd: bool, +) -> bool { + let mut winding: i32 = 0; + let mut crossings: i32 = 0; + for (s, e) in current_subpath_ranges(points, starts) { + let sub = &points[s..e]; + if sub.len() < 3 { + continue; + } + let n = sub.len(); + for i in 0..n { + let p1 = &sub[i]; + let p2 = &sub[(i + 1) % n]; + if (p1.y <= py) != (p2.y <= py) { + let t = (py - p1.y) / (p2.y - p1.y); + let xh = p1.x + t * (p2.x - p1.x); + if xh > px { + crossings += 1; + if p1.y < p2.y { + winding += 1; + } else { + winding -= 1; + } + } + } + } + } + if even_odd { + crossings % 2 != 0 + } else { + winding != 0 + } +} + +/// Approximate "point inside the stroked outline" test: return true +/// iff the point lies within `line_width / 2` of any edge of any +/// subpath. Matches the expected behavior of `isPointInStroke(x, y)` +/// for typical hit-testing use cases. +fn point_in_current_stroke( + points: &[renderer::Point], + starts: &[usize], + px: f64, + py: f64, + line_width: f64, +) -> bool { + let half = (line_width / 2.0).max(0.0); + if half == 0.0 { + return false; + } + let half_sq = half * half; + for (s, e) in current_subpath_ranges(points, starts) { + let sub = &points[s..e]; + if sub.len() < 2 { + continue; + } + let n = sub.len(); + for i in 0..n { + let a = &sub[i]; + let b = &sub[(i + 1) % n]; + let abx = b.x - a.x; + let aby = b.y - a.y; + let ax = px - a.x; + let ay = py - a.y; + let seg_len_sq = abx * abx + aby * aby; + let t = if seg_len_sq > 0.0 { + ((ax * abx + ay * aby) / seg_len_sq).clamp(0.0, 1.0) + } else { + 0.0 + }; + let cx = a.x + abx * t; + let cy = a.y + aby * t; + let dx = px - cx; + let dy = py - cy; + if dx * dx + dy * dy <= half_sq { + return true; + } + } + } + false +} + +/// Encode a byte buffer as a comma-separated string of decimal octets. +/// The TS wrappers for encoded-image ops split this back into a +/// `Uint8Array`. This mirrors the existing `internal_image_data_get_data` +/// pattern and avoids needing a zero-copy ArrayBuffer bridge today. +fn encode_bytes_csv(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 4); + for (i, b) in bytes.iter().enumerate() { + if i > 0 { + s.push(','); + } + s.push_str(&b.to_string()); + } + s +} + // Helper functions for text rendering fn calculate_baseline_offset( baseline: &state::TextBaseline, @@ -89,6 +220,11 @@ struct CanvasData<'gc> { // Path state for renderer current_path: Vec, path_started: bool, + // Indices into `current_path` where each subpath starts. Updated by + // `moveTo` to preserve earlier subpaths in the same `beginPath/fill` + // block — Canvas 2D paths can contain multiple disconnected subpaths + // that must each render as their own closed shape. + subpath_starts: Vec, // State stack for save/restore functionality state_stack: Vec, // Transformation matrix [a, b, c, d, e, f] @@ -149,7 +285,7 @@ impl CanvasExt { false, ), // Context2D operations - ExtensionOp::new("internal_canvas_arc", internal_canvas_arc, 5, false), + ExtensionOp::new("internal_canvas_arc", internal_canvas_arc, 6, false), ExtensionOp::new("internal_canvas_arc_to", internal_canvas_arc_to, 5, false), ExtensionOp::new( "internal_canvas_bezier_curve_to", @@ -196,7 +332,7 @@ impl CanvasExt { ExtensionOp::new( "internal_canvas_round_rect", internal_canvas_round_rect, - 6, + 9, false, ), ExtensionOp::new( @@ -425,6 +561,18 @@ impl CanvasExt { 2, false, ), + ExtensionOp::new( + "internal_canvas_reset_state_stack", + Self::internal_canvas_reset_state_stack, + 1, + false, + ), + ExtensionOp::new( + "internal_canvas_reset_bitmap", + Self::internal_canvas_reset_bitmap, + 1, + false, + ), ExtensionOp::new( "internal_canvas_get_global_composite_operation", Self::internal_canvas_get_global_composite_operation, @@ -443,6 +591,24 @@ impl CanvasExt { 1, false, ), + ExtensionOp::new( + "internal_canvas_encode_png", + Self::internal_canvas_encode_png, + 1, + false, + ), + ExtensionOp::new( + "internal_canvas_encode_jpeg", + Self::internal_canvas_encode_jpeg, + 2, + false, + ), + ExtensionOp::new( + "internal_canvas_encode_data_url", + Self::internal_canvas_encode_data_url, + 3, + false, + ), ExtensionOp::new( "internal_canvas_save_as_png", Self::internal_canvas_save_as_png, @@ -538,6 +704,30 @@ impl CanvasExt { 3, false, ), + ExtensionOp::new( + "internal_canvas_fill_path2d", + Self::internal_canvas_fill_path2d, + 2, + false, + ), + ExtensionOp::new( + "internal_canvas_stroke_path2d", + Self::internal_canvas_stroke_path2d, + 2, + false, + ), + ExtensionOp::new( + "internal_canvas_clip_path2d", + Self::internal_canvas_clip_path2d, + 2, + false, + ), + ExtensionOp::new( + "internal_canvas_clip_current", + Self::internal_canvas_clip_current, + 1, + false, + ), ExtensionOp::new( "internal_image_data_get_width", Self::internal_image_data_get_width, @@ -556,6 +746,12 @@ impl CanvasExt { 1, false, ), + ExtensionOp::new( + "internal_image_data_set_data", + Self::internal_image_data_set_data, + 2, + false, + ), // Gradient operations ExtensionOp::new( "internal_canvas_create_linear_gradient", @@ -603,7 +799,7 @@ impl CanvasExt { ExtensionOp::new( "internal_path2d_add_path", Self::internal_path2d_add_path, - 2, + 8, false, ), ExtensionOp::new( @@ -656,6 +852,18 @@ impl CanvasExt { 1, false, ), + ExtensionOp::new( + "internal_canvas_is_point_in_current_path", + Self::internal_canvas_is_point_in_current_path, + 4, + false, + ), + ExtensionOp::new( + "internal_canvas_is_point_in_current_stroke", + Self::internal_canvas_is_point_in_current_stroke, + 4, + false, + ), ExtensionOp::new( "internal_canvas_is_point_in_path", Self::internal_canvas_is_point_in_path, @@ -725,6 +933,7 @@ impl CanvasExt { shadow_offset_y: 0.0, current_path: Vec::new(), path_started: false, + subpath_starts: Vec::new(), state_stack: Vec::new(), // Identity transformation matrix transform: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], @@ -737,10 +946,10 @@ impl CanvasExt { direction: state::Direction::default(), }); - // Create renderer with GPU device + // Create renderer with GPU device. let (device, queue) = create_wgpu_device_sync(); let dimensions = renderer::Dimensions { width, height }; - let format = wgpu::TextureFormat::Bgra8UnormSrgb; // Common format for canvas + let format = wgpu::TextureFormat::Bgra8Unorm; let renderer = renderer::Renderer::new(device, queue, format, dimensions); let _renderer_rid = res.renderers.push(renderer); @@ -822,6 +1031,57 @@ impl CanvasExt { Ok(Value::Undefined) } + /// Clear the canvas's save/restore state stack AND its current + /// subpath + subpath_starts. Used by `ctx.reset()` to make the + /// subsequent `restore()` a no-op. + fn internal_canvas_reset_state_stack<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let rid_val = args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32; + let rid = Rid::from_index(rid_val); + let host_data = agent + .get_host_data() + .downcast_ref::>() + .unwrap(); + let mut storage = host_data.storage.borrow_mut(); + let res: &mut CanvasResources = storage.get_mut().unwrap(); + if let Some(mut data) = res.canvases.get_mut(rid) { + data.state_stack.clear(); + data.current_path.clear(); + data.subpath_starts.clear(); + data.path_started = false; + data.commands.clear(); + } + Ok(Value::Undefined) + } + + /// Clear the canvas bitmap to fully transparent. Used by + /// `ctx.reset()` so the canvas surface itself is cleared. + fn internal_canvas_reset_bitmap<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let rid_val = args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32; + let rid = Rid::from_index(rid_val); + let host_data = agent + .get_host_data() + .downcast_ref::>() + .unwrap(); + let mut storage = host_data.storage.borrow_mut(); + let res: &mut CanvasResources = storage.get_mut().unwrap(); + if let Some(mut renderer) = res.renderers.get_mut(rid) { + renderer.commands.clear(); + renderer.clip_path = None; + renderer.render_all(); + } + Ok(Value::Undefined) + } + /// Internal op to set the globalCompositeOperation of a canvas context #[allow(dead_code)] fn internal_canvas_set_global_composite_operation<'gc>( @@ -1157,6 +1417,165 @@ impl CanvasExt { } /// Internal op to save canvas as PNG file + /// Encode the rendered canvas as PNG bytes and return them as a + /// comma-separated-decimal string. TS wraps this into a Uint8Array + /// (matches the existing `internal_image_data_get_data` pattern). + fn internal_canvas_encode_png<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let rid_val = args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32; + let rid = Rid::from_index(rid_val); + let result = Self::with_renderer(agent, rid, |renderer| { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("tokio runtime init failed: {e}"))?; + rt.block_on(renderer.encode_as_png()) + .map_err(|e| format!("PNG encode failed: {e}")) + }); + match result { + Some(Ok(bytes)) => { + Ok(Value::from_string(agent, encode_bytes_csv(&bytes), gc.nogc()).unbind()) + } + Some(Err(msg)) => Err(agent + .throw_exception(ExceptionType::Error, msg, gc.nogc()) + .unbind()), + None => Err(agent + .throw_exception( + ExceptionType::Error, + "toBuffer: canvas has no associated renderer".to_string(), + gc.nogc(), + ) + .unbind()), + } + } + + /// Encode the rendered canvas as JPEG bytes at the given quality + /// (0..=100). Return as a comma-separated-decimal string. + fn internal_canvas_encode_jpeg<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let rid_val = args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32; + let rid = Rid::from_index(rid_val); + let quality_val = args + .get(1) + .to_number(agent, gc.reborrow()) + .unbind()? + .into_f64(agent); + let quality_f = if quality_val.is_finite() { + quality_val.clamp(0.0, 1.0) + } else { + 0.92 + }; + let quality = (quality_f * 100.0).round() as u8; + let result = Self::with_renderer(agent, rid, |renderer| { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("tokio runtime init failed: {e}"))?; + rt.block_on(renderer.encode_as_jpeg(quality)) + .map_err(|e| format!("JPEG encode failed: {e}")) + }); + match result { + Some(Ok(bytes)) => { + Ok(Value::from_string(agent, encode_bytes_csv(&bytes), gc.nogc()).unbind()) + } + Some(Err(msg)) => Err(agent + .throw_exception(ExceptionType::Error, msg, gc.nogc()) + .unbind()), + None => Err(agent + .throw_exception( + ExceptionType::Error, + "toBuffer: canvas has no associated renderer".to_string(), + gc.nogc(), + ) + .unbind()), + } + } + + /// Encode the canvas as a `data:;base64,` string. `mime` + /// must be `"image/png"` or `"image/jpeg"`; any other value is treated + /// as `"image/png"`. + fn internal_canvas_encode_data_url<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let rid_val = args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32; + let rid = Rid::from_index(rid_val); + let mime_str = args.get(1).to_string(agent, gc.reborrow()).unbind()?; + let mime = mime_str.as_str(agent).unwrap_or("image/png").to_owned(); + let quality_val = args + .get(2) + .to_number(agent, gc.reborrow()) + .unbind() + .map(|n| n.into_f64(agent)) + .unwrap_or(0.92); + let quality = (quality_val.clamp(0.0, 1.0) * 100.0).round() as u8; + + let result = Self::with_renderer(agent, rid, |renderer| { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| format!("tokio runtime init failed: {e}"))?; + if mime == "image/jpeg" || mime == "image/jpg" { + let b = rt + .block_on(renderer.encode_as_jpeg(quality)) + .map_err(|e| format!("JPEG encode failed: {e}"))?; + Ok::<(String, Vec), String>(("image/jpeg".to_string(), b)) + } else { + let b = rt + .block_on(renderer.encode_as_png()) + .map_err(|e| format!("PNG encode failed: {e}"))?; + Ok(("image/png".to_string(), b)) + } + }); + let (mime_out, bytes) = match result { + Some(Ok(v)) => v, + Some(Err(msg)) => { + return Err(agent + .throw_exception(ExceptionType::Error, msg, gc.nogc()) + .unbind()); + } + None => { + return Err(agent + .throw_exception( + ExceptionType::Error, + "toDataURL: canvas has no associated renderer".to_string(), + gc.nogc(), + ) + .unbind()); + } + }; + + use base64_simd::STANDARD; + let b64 = STANDARD.encode_to_string(&bytes); + let data_url = format!("data:{};base64,{}", mime_out, b64); + Ok(Value::from_string(agent, data_url, gc.nogc()).unbind()) + } + + fn with_renderer(agent: &mut Agent, rid: Rid, f: F) -> Option + where + F: FnOnce(&mut renderer::Renderer) -> R, + { + let host_data = agent + .get_host_data() + .downcast_ref::>() + .unwrap(); + let mut storage = host_data.storage.borrow_mut(); + let res: &mut CanvasResources = storage.get_mut().unwrap(); + let mut renderer = res.renderers.get_mut(rid)?; + renderer.render_all(); + Some(f(&mut renderer)) + } + fn internal_canvas_save_as_png<'gc>( agent: &mut Agent, _this: Value<'_>, @@ -1256,10 +1675,8 @@ impl CanvasExt { FillStyle::ConicGradient(gradient) => { Ok(Value::from_i64(agent, gradient.rid as i64, gc.nogc()).unbind()) } - FillStyle::Pattern { image_rid, .. } => { - // Return the image RID as a number for now - // In a full implementation, this would return a CanvasPattern object - Ok(Value::from_i64(agent, *image_rid as i64, gc.nogc()).unbind()) + FillStyle::Pattern { pattern_rid, .. } => { + Ok(Value::from_i64(agent, *pattern_rid as i64, gc.nogc()).unbind()) } } } @@ -1358,9 +1775,8 @@ impl CanvasExt { FillStyle::ConicGradient(gradient) => { Ok(Value::from_i64(agent, gradient.rid as i64, gc.nogc()).unbind()) } - FillStyle::Pattern { image_rid, .. } => { - // Return the image RID as a number for now - Ok(Value::from_i64(agent, *image_rid as i64, gc.nogc()).unbind()) + FillStyle::Pattern { pattern_rid, .. } => { + Ok(Value::from_i64(agent, *pattern_rid as i64, gc.nogc()).unbind()) } } } @@ -1585,6 +2001,24 @@ impl CanvasExt { let target_rid = Rid::from_index(target_rid_val); let source_rid = Rid::from_index(source_rid_val); + let defaults = [1.0f64, 0.0, 0.0, 1.0, 0.0, 0.0]; + let mut matrix = defaults; + let mut has_non_identity = false; + for i in 0..6 { + let arg = args.get(2 + i); + let v = arg + .to_number(agent, gc.reborrow()) + .unbind() + .map(|n| n.into_f64(agent)) + .unwrap_or(defaults[i]); + if v.is_finite() { + matrix[i] = v; + if (matrix[i] - defaults[i]).abs() > f64::EPSILON { + has_non_identity = true; + } + } + } + let host_data = agent .get_host_data() .downcast_ref::>() @@ -1593,11 +2027,301 @@ impl CanvasExt { let res: &mut CanvasResources = storage.get_mut().unwrap(); let source_path = res.path2ds.get(source_rid).unwrap().clone(); + let transform = if has_non_identity { Some(matrix) } else { None }; res.path2ds .get_mut(target_rid) .unwrap() - .add_path(&source_path, None); // TODO: Add transform support + .add_path(&source_path, transform); + + Ok(Value::Undefined) + } + + /// Test if a point is inside the context's current subpath(s), + /// per the HTML spec's no-Path2D overload of `isPointInPath`. + /// Uses ray-casting winding; switches to even-odd when fillRule is + /// "evenodd". + fn internal_canvas_is_point_in_current_path<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let rid_val = args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32; + let x = args + .get(1) + .to_number(agent, gc.reborrow()) + .unbind()? + .into_f64(agent); + let y = args + .get(2) + .to_number(agent, gc.reborrow()) + .unbind()? + .into_f64(agent); + let fill_rule_str = args.get(3).to_string(agent, gc.reborrow()).unbind()?; + let even_odd = matches!(fill_rule_str.as_str(agent), Some("evenodd")); + + let rid = Rid::from_index(rid_val); + let host_data = agent + .get_host_data() + .downcast_ref::>() + .unwrap(); + let storage = host_data.storage.borrow(); + let res: &CanvasResources = storage.get().unwrap(); + let Some(data) = res.canvases.get(rid) else { + return Ok(Value::Boolean(false)); + }; + let result = + point_in_current_path(&data.current_path, &data.subpath_starts, x, y, even_odd); + Ok(Value::Boolean(result)) + } + + /// Test if a point is inside the stroke of the context's current + /// subpath(s). Approximates by checking distance from every edge + /// against `line_width / 2`. + fn internal_canvas_is_point_in_current_stroke<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let rid_val = args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32; + let x = args + .get(1) + .to_number(agent, gc.reborrow()) + .unbind()? + .into_f64(agent); + let y = args + .get(2) + .to_number(agent, gc.reborrow()) + .unbind()? + .into_f64(agent); + let line_width = args + .get(3) + .to_number(agent, gc.reborrow()) + .unbind()? + .into_f64(agent); + let rid = Rid::from_index(rid_val); + let host_data = agent + .get_host_data() + .downcast_ref::>() + .unwrap(); + let storage = host_data.storage.borrow(); + let res: &CanvasResources = storage.get().unwrap(); + let Some(data) = res.canvases.get(rid) else { + return Ok(Value::Boolean(false)); + }; + let result = + point_in_current_stroke(&data.current_path, &data.subpath_starts, x, y, line_width); + Ok(Value::Boolean(result)) + } + + /// Fill a Path2D onto the given canvas. Routes every subpath through + /// the same fan-triangulation pipeline that `internal_canvas_fill` + /// uses for the current subpath. + fn internal_canvas_fill_path2d<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let canvas_rid_val = args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32; + let path_rid_val = args.get(1).to_int32(agent, gc.reborrow()).unbind()? as u32; + let canvas_rid = Rid::from_index(canvas_rid_val); + let path_rid = Rid::from_index(path_rid_val); + + let host_data = agent + .get_host_data() + .downcast_ref::>() + .unwrap(); + let mut storage = host_data.storage.borrow_mut(); + let res: &mut CanvasResources = storage.get_mut().unwrap(); + + let Some(path) = res.path2ds.get(path_rid) else { + return Ok(Value::Undefined); + }; + // Materialize subpaths so we can drop the Path2D borrow before + // reaching for a mutable renderer below. + let subpaths: Vec> = path + .subpaths + .iter() + .filter(|s| s.points.len() >= 3) + .map(|s| s.points.clone()) + .collect(); + drop(path); + + if let Some(mut renderer) = res.renderers.get_mut(canvas_rid) { + let data = res.canvases.get(canvas_rid).unwrap(); + let state = RenderState { + fill_style: data.fill_style.clone(), + global_alpha: data.global_alpha, + transform: data.transform, + line_cap: data.line_cap, + line_join: data.line_join, + miter_limit: data.miter_limit, + shadow_blur: data.shadow_blur, + shadow_color: data.shadow_color.clone(), + shadow_offset_x: data.shadow_offset_x, + shadow_offset_y: data.shadow_offset_y, + composite_operation: data.composite_operation, + clip_path: None, + }; + for subpath in subpaths { + renderer.render_polygon(subpath, &state); + } + } + Ok(Value::Undefined) + } + + /// Stroke a Path2D onto the given canvas. Each subpath goes through + /// the stroke-triangulation pipeline independently. + fn internal_canvas_stroke_path2d<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let canvas_rid_val = args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32; + let path_rid_val = args.get(1).to_int32(agent, gc.reborrow()).unbind()? as u32; + let canvas_rid = Rid::from_index(canvas_rid_val); + let path_rid = Rid::from_index(path_rid_val); + + let host_data = agent + .get_host_data() + .downcast_ref::>() + .unwrap(); + let mut storage = host_data.storage.borrow_mut(); + let res: &mut CanvasResources = storage.get_mut().unwrap(); + + let Some(path) = res.path2ds.get(path_rid) else { + return Ok(Value::Undefined); + }; + let subpaths: Vec> = path + .subpaths + .iter() + .filter(|s| s.points.len() >= 2) + .map(|s| { + let mut pts = s.points.clone(); + if s.closed + && let (Some(first), Some(last)) = (pts.first(), pts.last()) + && (first.x != last.x || first.y != last.y) + { + pts.push(pts[0].clone()); + } + pts + }) + .collect(); + drop(path); + + if let Some(mut renderer) = res.renderers.get_mut(canvas_rid) { + let data = res.canvases.get(canvas_rid).unwrap(); + let state = RenderState { + fill_style: data.stroke_style.clone(), + global_alpha: data.global_alpha, + transform: data.transform, + line_cap: data.line_cap, + line_join: data.line_join, + miter_limit: data.miter_limit, + shadow_blur: data.shadow_blur, + shadow_color: data.shadow_color.clone(), + shadow_offset_x: data.shadow_offset_x, + shadow_offset_y: data.shadow_offset_y, + composite_operation: data.composite_operation, + clip_path: None, + }; + let line_width = data.line_width; + let line_dash = data.line_dash.clone(); + let dash_offset = data.line_dash_offset; + let line_cap = data.line_cap; + let line_join = data.line_join; + let miter_limit = data.miter_limit; + for subpath in subpaths { + let tris = crate::ext::canvas::context2d::generate_stroke_path_public( + &subpath, + line_width, + &line_dash, + dash_offset, + line_cap, + line_join, + miter_limit, + ); + if !tris.is_empty() { + renderer.render_triangles(tris, &state); + } + } + } + Ok(Value::Undefined) + } + + /// Clip the canvas to the Path2D's first non-empty subpath. The + /// stencil pipeline only supports a single-subpath clip today, so + /// multi-subpath Path2Ds are approximated by the first subpath. + fn internal_canvas_clip_path2d<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let canvas_rid_val = args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32; + let path_rid_val = args.get(1).to_int32(agent, gc.reborrow()).unbind()? as u32; + let canvas_rid = Rid::from_index(canvas_rid_val); + let path_rid = Rid::from_index(path_rid_val); + + let host_data = agent + .get_host_data() + .downcast_ref::>() + .unwrap(); + let mut storage = host_data.storage.borrow_mut(); + let res: &mut CanvasResources = storage.get_mut().unwrap(); + + let Some(path) = res.path2ds.get(path_rid) else { + return Ok(Value::Undefined); + }; + let first = path + .subpaths + .iter() + .find(|s| s.points.len() >= 3) + .map(|s| s.points.clone()); + drop(path); + + if let Some(points) = first + && let Some(mut renderer) = res.renderers.get_mut(canvas_rid) + { + renderer.set_clip_path(Some(points)); + } + Ok(Value::Undefined) + } + + /// Clip the canvas to the context's current subpath. Same + /// single-subpath approximation as clip_path2d. + fn internal_canvas_clip_current<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let rid_val = args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32; + let rid = Rid::from_index(rid_val); + let host_data = agent + .get_host_data() + .downcast_ref::>() + .unwrap(); + let mut storage = host_data.storage.borrow_mut(); + let res: &mut CanvasResources = storage.get_mut().unwrap(); + + let Some(data) = res.canvases.get(rid) else { + return Ok(Value::Undefined); + }; + let first_subpath = current_subpath_ranges(&data.current_path, &data.subpath_starts) + .into_iter() + .map(|(s, e)| data.current_path[s..e].to_vec()) + .find(|s| s.len() >= 3); + drop(data); + if let Some(points) = first_subpath + && let Some(mut renderer) = res.renderers.get_mut(rid) + { + renderer.set_clip_path(Some(points)); + } Ok(Value::Undefined) } @@ -2778,27 +3502,19 @@ impl CanvasExt { let mut storage = host_data.storage.borrow_mut(); let res: &mut CanvasResources = storage.get_mut().unwrap(); - // Get renderer and read pixel data - let renderer_rid = res.canvases.get(canvas_rid).map(|_| Rid::from_index(0)); - - let pixel_data = if let Some(renderer_rid) = renderer_rid { - if let Some(mut renderer) = res.renderers.get_mut(renderer_rid) { - // Render all pending commands first + // Each canvas owns its own renderer at the same rid. + let pixel_data = if res.canvases.get(canvas_rid).is_some() { + if let Some(mut renderer) = res.renderers.get_mut(canvas_rid) { renderer.render_all(); - - // Read pixels from GPU (this requires async, so we'll use block_on) - let bitmap = futures::executor::block_on(renderer.create_bitmap()); - - // Extract the requested region - extract_image_region( - &bitmap, - renderer.dimensions.width, - renderer.dimensions.height, - sx, - sy, - sw, - sh, - ) + match futures::executor::block_on(renderer.snapshot_as_image()) { + Ok(img) => { + let full_w = img.width(); + let full_h = img.height(); + let raw = img.into_raw(); + extract_image_region(&raw, full_w, full_h, sx, sy, sw, sh) + } + Err(_) => vec![0u8; (sw * sh * 4) as usize], + } } else { vec![0u8; (sw * sh * 4) as usize] } @@ -2844,34 +3560,60 @@ impl CanvasExt { let mut storage = host_data.storage.borrow_mut(); let res: &mut CanvasResources = storage.get_mut().unwrap(); - // Get image data and load it as a texture - if let Some(image_data) = res.images.get(image_data_rid) { - let width = image_data.width; - let height = image_data.height; - - if let Some(data) = &image_data.data { - // Load the image data into a temporary texture - let renderer_rid = Rid::from_index(0); // Assume single renderer for now - if let Some(mut renderer) = res.renderers.get_mut(renderer_rid) { - let temp_image_rid = u32::MAX; // Use special ID for temp texture - renderer.load_image_texture(temp_image_rid, data, width, height); - } + // Resolve the ImageData's pixel buffer + dimensions, then drop + // the immutable borrow before we grab the renderer mutably. + let image_info = res.images.get(image_data_rid).and_then(|img| { + let width = img.width; + let height = img.height; + img.data.as_ref().map(|d| (width, height, d.clone())) + }); + let Some((width, height, data)) = image_info else { + return Ok(Value::Undefined); + }; - // Add draw command - if let Some(mut canvas) = res.canvases.get_mut(canvas_rid) { - canvas.commands.push(context2d::CanvasCommand::DrawImage { - image_rid: u32::MAX, - sx: 0.0, - sy: 0.0, - s_width: width as f64, - s_height: height as f64, - dx, - dy, - d_width: width as f64, - d_height: height as f64, - }); - } - } + // Render directly — do NOT queue on canvas.commands, since + // `process_all_commands` is never wired into render_all. + // putImageData also bypasses the current transform and globalAlpha. + if let Some(mut renderer) = res.renderers.get_mut(canvas_rid) { + let temp_image_rid = u32::MAX; + renderer.load_image_texture(temp_image_rid, &data, width, height); + + let render_state = RenderState { + fill_style: FillStyle::Color { + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + }, + global_alpha: 1.0, + transform: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + line_cap: renderer::LineCap::default(), + line_join: renderer::LineJoin::default(), + miter_limit: 10.0, + shadow_blur: 0.0, + shadow_color: FillStyle::Color { + r: 0.0, + g: 0.0, + b: 0.0, + a: 0.0, + }, + shadow_offset_x: 0.0, + shadow_offset_y: 0.0, + composite_operation: renderer::CompositeOperation::default(), + clip_path: None, + }; + renderer.render_image( + temp_image_rid, + 0.0, + 0.0, + width as f64, + height as f64, + dx, + dy, + width as f64, + height as f64, + &render_state, + ); } Ok(Value::Undefined) @@ -2954,7 +3696,43 @@ impl CanvasExt { Ok(Value::Undefined) } - // ========== PHASE 2 IMPLEMENTATIONS: LINE STYLES ========== + fn internal_image_data_set_data<'gc>( + agent: &mut Agent, + _this: Value<'_>, + args: ArgumentsList<'_, '_>, + mut gc: GcScope<'gc, '_>, + ) -> JsResult<'gc, Value<'gc>> { + let rid = Rid::from_index(args.get(0).to_int32(agent, gc.reborrow()).unbind()? as u32); + let csv = args.get(1).to_string(agent, gc.reborrow()).unbind()?; + let csv_str = csv.as_str(agent).unwrap_or(""); + + let host_data = agent + .get_host_data() + .downcast_ref::>() + .unwrap(); + let mut storage = host_data.storage.borrow_mut(); + let res: &mut CanvasResources = storage.get_mut().unwrap(); + + if let Some(mut image_data) = res.images.get_mut(rid) { + let expected = (image_data.width * image_data.height * 4) as usize; + let mut out = Vec::with_capacity(expected); + let stripped = csv_str + .strip_prefix('[') + .and_then(|s| s.strip_suffix(']')) + .unwrap_or(csv_str); + for piece in stripped.split(',') { + if out.len() >= expected { + break; + } + if let Ok(n) = piece.trim().parse::() { + out.push(n.min(255) as u8); + } + } + out.resize(expected, 0); + image_data.data = Some(out); + } + Ok(Value::Undefined) + } /// Internal op to set lineCap property fn internal_canvas_set_line_cap<'gc>( @@ -3783,7 +4561,6 @@ impl CanvasExt { let align_offset = calculate_alignment_offset(&canvas.text_align, &canvas.direction, width as f64); - // Render text bitmap as textured rectangle renderer.render_image( texture_id, 0.0, @@ -3925,8 +4702,6 @@ impl CanvasExt { Ok(Value::Undefined) } - // ========== PHASE 2 IMPLEMENTATIONS: PATTERNS ========== - /// Internal op to create a pattern from an image with repetition mode fn internal_canvas_create_pattern<'gc>( agent: &mut Agent, @@ -3949,12 +4724,18 @@ impl CanvasExt { .parse::() .unwrap_or(fill_style::PatternRepetition::Repeat); - let pattern = FillStyle::Pattern { + let pattern_rid = res.fill_styles.push(FillStyle::Pattern { + pattern_rid: 0, image_rid, - repetition, - }; - - let pattern_rid = res.fill_styles.push(pattern); + repetition: repetition.clone(), + }); + if let Some(mut entry) = res.fill_styles.get_mut(pattern_rid) { + *entry = FillStyle::Pattern { + pattern_rid: pattern_rid.index(), + image_rid, + repetition, + }; + } Ok(Value::Integer(SmallInteger::from( pattern_rid.index() as i32 diff --git a/crates/runtime/src/ext/canvas/mod.ts b/crates/runtime/src/ext/canvas/mod.ts index 013c0203..c6ac176a 100644 --- a/crates/runtime/src/ext/canvas/mod.ts +++ b/crates/runtime/src/ext/canvas/mod.ts @@ -4,6 +4,323 @@ type CanvasFillRule = "nonzero" | "evenodd"; +interface DOMMatrix2DInit { + a?: number; + b?: number; + c?: number; + d?: number; + e?: number; + f?: number; + m11?: number; + m12?: number; + m21?: number; + m22?: number; + m41?: number; + m42?: number; +} + +/** + * DOMMatrix (2D subset) — the affine 2D transform class used by Canvas + * and exposed on `globalThis` per the DOMMatrix spec. Only the 2D fields + * (a, b, c, d, e, f) are modeled; 3D matrix math is not implemented + * because canvas 2D never uses it. + * + * Mutable. See `DOMMatrixReadOnly` for the immutable variant. + * + * Reference: https://drafts.fxtf.org/geometry/#dommatrix + */ +class DOMMatrixReadOnly { + a: number = 1; + b: number = 0; + c: number = 0; + d: number = 1; + e: number = 0; + f: number = 0; + is2D: boolean = true; + + constructor(init?: number[] | DOMMatrix2DInit | string) { + const fields = parseMatrixInit(init); + this.a = fields.a; + this.b = fields.b; + this.c = fields.c; + this.d = fields.d; + this.e = fields.e; + this.f = fields.f; + } + + get m11(): number { + return this.a; + } + get m12(): number { + return this.b; + } + get m21(): number { + return this.c; + } + get m22(): number { + return this.d; + } + get m41(): number { + return this.e; + } + get m42(): number { + return this.f; + } + + get isIdentity(): boolean { + return ( + this.a === 1 && + this.b === 0 && + this.c === 0 && + this.d === 1 && + this.e === 0 && + this.f === 0 + ); + } + + /** Returns a new DOMMatrix = this * other. */ + multiply(other: DOMMatrix2DInit | DOMMatrixReadOnly): DOMMatrix { + const o = parseMatrixInit(other as DOMMatrix2DInit); + return new DOMMatrix([ + this.a * o.a + this.c * o.b, + this.b * o.a + this.d * o.b, + this.a * o.c + this.c * o.d, + this.b * o.c + this.d * o.d, + this.a * o.e + this.c * o.f + this.e, + this.b * o.e + this.d * o.f + this.f, + ]); + } + + translate(tx: number, ty: number = 0): DOMMatrix { + return this.multiply({ a: 1, b: 0, c: 0, d: 1, e: tx, f: ty }); + } + + scale(sx: number, sy: number = sx): DOMMatrix { + return this.multiply({ a: sx, b: 0, c: 0, d: sy, e: 0, f: 0 }); + } + + /** Rotation in degrees per the DOMMatrix spec. */ + rotate(angleDegrees: number): DOMMatrix { + const rad = (angleDegrees * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + return this.multiply({ a: cos, b: sin, c: -sin, d: cos, e: 0, f: 0 }); + } + + /** Returns the inverse; a NaN-filled matrix when not invertible, per spec. */ + inverse(): DOMMatrix { + const det = this.a * this.d - this.b * this.c; + if (det === 0 || !Number.isFinite(det)) { + return new DOMMatrix([NaN, NaN, NaN, NaN, NaN, NaN]); + } + const inv = 1 / det; + return new DOMMatrix([ + this.d * inv, + -this.b * inv, + -this.c * inv, + this.a * inv, + (this.c * this.f - this.d * this.e) * inv, + (this.b * this.e - this.a * this.f) * inv, + ]); + } + + transformPoint(point: { x: number; y: number }): { x: number; y: number } { + return { + x: this.a * point.x + this.c * point.y + this.e, + y: this.b * point.x + this.d * point.y + this.f, + }; + } + + toFloat32Array(): Float32Array { + // Canvas 2D uses only the 2D subset — expose the 6 meaningful entries + // padded into a Float32Array of length 6. + return new Float32Array([this.a, this.b, this.c, this.d, this.e, this.f]); + } + + toString(): string { + return `matrix(${this.a}, ${this.b}, ${this.c}, ${this.d}, ${this.e}, ${this.f})`; + } +} + +class DOMMatrix extends DOMMatrixReadOnly { + constructor(init?: number[] | DOMMatrix2DInit | string) { + super(init); + } + + multiplySelf(other: DOMMatrix2DInit | DOMMatrixReadOnly): DOMMatrix { + const m = this.multiply(other); + this.a = m.a; + this.b = m.b; + this.c = m.c; + this.d = m.d; + this.e = m.e; + this.f = m.f; + return this; + } + + translateSelf(tx: number, ty: number = 0): DOMMatrix { + return this.multiplySelf({ a: 1, b: 0, c: 0, d: 1, e: tx, f: ty }); + } + + scaleSelf(sx: number, sy: number = sx): DOMMatrix { + return this.multiplySelf({ a: sx, b: 0, c: 0, d: sy, e: 0, f: 0 }); + } + + rotateSelf(angleDegrees: number): DOMMatrix { + const rad = (angleDegrees * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + return this.multiplySelf({ a: cos, b: sin, c: -sin, d: cos, e: 0, f: 0 }); + } + + invertSelf(): DOMMatrix { + const inv = this.inverse(); + this.a = inv.a; + this.b = inv.b; + this.c = inv.c; + this.d = inv.d; + this.e = inv.e; + this.f = inv.f; + return this; + } +} + +/** Coerce any supported DOMMatrix init form into a canonical 2D-affine struct. */ +function parseMatrixInit( + init?: number[] | DOMMatrix2DInit | DOMMatrixReadOnly | string, +): { a: number; b: number; c: number; d: number; e: number; f: number } { + const identity = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; + if (init === undefined || init === null) return identity; + if (Array.isArray(init)) { + if (init.length === 6) { + return { + a: init[0], + b: init[1], + c: init[2], + d: init[3], + e: init[4], + f: init[5], + }; + } + if (init.length === 16) { + return { + a: init[0], + b: init[1], + c: init[4], + d: init[5], + e: init[12], + f: init[13], + }; + } + throw new TypeError( + `DOMMatrix constructor: array must have length 6 or 16, got ${init.length}`, + ); + } + if (typeof init === "string") { + // Minimal parser for `matrix(a,b,c,d,e,f)` — other CSS transform forms + // fall back to identity. + const m = init.match( + /matrix\(\s*([-+.eE0-9]+)\s*,\s*([-+.eE0-9]+)\s*,\s*([-+.eE0-9]+)\s*,\s*([-+.eE0-9]+)\s*,\s*([-+.eE0-9]+)\s*,\s*([-+.eE0-9]+)\s*\)/, + ); + if (m) { + return { + a: parseFloat(m[1]), + b: parseFloat(m[2]), + c: parseFloat(m[3]), + d: parseFloat(m[4]), + e: parseFloat(m[5]), + f: parseFloat(m[6]), + }; + } + return identity; + } + // DOMMatrix2DInit or existing DOMMatrix instance — read a..f with fallbacks + // through the m11..m42 aliases. + const obj = init as DOMMatrix2DInit; + return { + a: obj.a ?? obj.m11 ?? 1, + b: obj.b ?? obj.m12 ?? 0, + c: obj.c ?? obj.m21 ?? 0, + d: obj.d ?? obj.m22 ?? 1, + e: obj.e ?? obj.m41 ?? 0, + f: obj.f ?? obj.m42 ?? 0, + }; +} + +/** + * Per-spec normalization for roundRect's `radii` argument. + * Accepts a single number, a DOMPointInit-like `{x, y}`, or an array of + * 1-4 of either. Collapses to a single representative radius (the max + * corner radius) until the renderer gains per-corner support. + * + * Throws `RangeError` for negative or non-finite values per the spec. + */ +/** + * True if `value` is a Path2D instance. Used to dispatch the union + * overloads on CanvasRenderingContext2D methods (fill, stroke, clip, + * isPointInPath, isPointInStroke). + */ +function isPath2D(value: unknown): value is Path2D { + return ( + typeof value === "object" && + value !== null && + typeof (value as Path2D).getRid === "function" + ); +} + +function normalizeRoundRectRadii( + radii?: + | number + | { x: number; y: number } + | Array, +): [number, number, number, number] { + const coerce = (r: number | { x: number; y: number } | undefined): number => { + if (r === undefined) return 0; + if (typeof r === "number") { + if (!Number.isFinite(r) || r < 0) { + throw new RangeError( + `The radius provided (${r}) is negative or non-finite.`, + ); + } + return r; + } + const rx = typeof r.x === "number" ? r.x : 0; + const ry = typeof r.y === "number" ? r.y : 0; + if (!Number.isFinite(rx) || !Number.isFinite(ry) || rx < 0 || ry < 0) { + throw new RangeError( + `A radius provided (${rx},${ry}) is negative or non-finite.`, + ); + } + // DOMPointInit {x, y} collapses to max(x, y) for this pass; true + // elliptical per-corner radii are a renderer follow-up. + return Math.max(rx, ry); + }; + if (radii === undefined || radii === null) return [0, 0, 0, 0]; + if (!Array.isArray(radii)) { + const v = coerce(radii); + return [v, v, v, v]; + } + if (radii.length === 0) return [0, 0, 0, 0]; + if (radii.length > 4) { + throw new RangeError( + `roundRect accepts at most 4 radii, received ${radii.length}.`, + ); + } + const c = radii.map(coerce); + // Spec distribution rules: 1 → all four; 2 → (tl/br, tr/bl); + // 3 → (tl, tr/bl, br); 4 → (tl, tr, br, bl). + switch (c.length) { + case 1: + return [c[0], c[0], c[0], c[0]]; + case 2: + return [c[0], c[1], c[0], c[1]]; + case 3: + return [c[0], c[1], c[2], c[1]]; + default: + return [c[0], c[1], c[2], c[3]]; + } +} + /** * A Path2D implementation for representing vector paths */ @@ -31,11 +348,25 @@ class Path2D { } /** - * Adds a path to the current path. + * Adds a path to the current path, optionally transformed by a + * `DOMMatrix2DInit` / `DOMMatrix`. The transform is applied point-by- + * point to the source path's subpaths as they're copied in. */ - addPath(path: Path2D, _transform?: object): void { - // TODO: Add transformation matrix support - __andromeda__.internal_path2d_add_path(this.#rid, path.getRid()); + addPath( + path: Path2D, + transform?: DOMMatrix2DInit | DOMMatrixReadOnly | null, + ): void { + const m = parseMatrixInit(transform ?? undefined); + __andromeda__.internal_path2d_add_path( + this.#rid, + path.getRid(), + m.a, + m.b, + m.c, + m.d, + m.e, + m.f, + ); } /** @@ -151,19 +482,24 @@ class Path2D { } /** - * Adds a rounded rectangle to the path. + * Adds a rounded rectangle to the path per the HTML Canvas spec. + * `radii` accepts `number`, `{x, y}`, or an array of 1-4 of those. */ roundRect( x: number, y: number, w: number, h: number, - radii?: number | number[], + radii?: + | number + | { x: number; y: number } + | Array, ): void { - const radiiArray = Array.isArray(radii) ? - radii : - (typeof radii === "number" ? [radii] : [0]); - __andromeda__.internal_path2d_round_rect(this.#rid, x, y, w, h, radiiArray); + const corners = normalizeRoundRectRadii(radii); + // Path2D's Rust op accepts an array of radii and handles all four + // distribution patterns internally via round_rect_web_api; pass the + // fully-distributed [tl, tr, br, bl] to avoid double-normalization. + __andromeda__.internal_path2d_round_rect(this.#rid, x, y, w, h, corners); } /** @@ -239,17 +575,115 @@ class OffscreenCanvas { * Returns true if save was successful, false otherwise. */ saveAsPng(path: string): boolean { - return this.render() ? - __andromeda__.internal_canvas_save_as_png(this.#rid, path) : - false; + return this.render() + ? __andromeda__.internal_canvas_save_as_png(this.#rid, path) + : false; + } + + /** + * Encode the canvas as a `Uint8Array` of image bytes. + * + * Supported types: `"image/png"` (default) and `"image/jpeg"`. + * `quality` is in [0, 1] and only applies to JPEG (default 0.92). + */ + toBuffer(type: string = "image/png", quality: number = 0.92): Uint8Array { + this.render(); + const mime = (type ?? "image/png").toLowerCase(); + let csv: string; + if (mime === "image/jpeg" || mime === "image/jpg") { + const q = Number.isFinite(quality) + ? Math.min(1, Math.max(0, quality)) + : 0.92; + csv = __andromeda__.internal_canvas_encode_jpeg(this.#rid, q); + } else if (mime === "image/png") { + csv = __andromeda__.internal_canvas_encode_png(this.#rid); + } else { + throw new TypeError( + `toBuffer: unsupported type "${type}" (only "image/png" and "image/jpeg" are supported).`, + ); + } + return decodeCsvBytes(csv); + } + + /** + * Encode the canvas as a `data:;base64,` URL string. + * + * Per the HTML spec, unsupported MIME types silently fall back to PNG. + * `quality` in [0, 1] applies only to JPEG; default 0.92. + */ + toDataURL(type: string = "image/png", quality: number = 0.92): string { + this.render(); + const mime = (type ?? "image/png").toLowerCase(); + const q = Number.isFinite(quality) + ? Math.min(1, Math.max(0, quality)) + : 0.92; + return __andromeda__.internal_canvas_encode_data_url(this.#rid, mime, q); } + + /** + * Encode the canvas as a Blob of image bytes. + * Spec: returns a Promise even though encoding is synchronous here. + */ + convertToBlob(options?: { type?: string; quality?: number }): Promise { + const type = options?.type ?? "image/png"; + const quality = options?.quality ?? 0.92; + return new Promise((resolve, reject) => { + try { + const bytes = this.toBuffer(type, quality); + resolve(new Blob([bytes], { type })); + } catch (e) { + reject(e); + } + }); + } +} + +/** + * Decode the comma-separated-decimal byte string used by the Rust-side + * encode ops back into a `Uint8Array`. + */ +function decodeCsvBytes(csv: string): Uint8Array { + if (typeof csv !== "string" || csv.length === 0) return new Uint8Array(0); + const parts = csv.split(","); + const out = new Uint8Array(parts.length); + for (let i = 0; i < parts.length; i++) { + const n = parseInt(parts[i], 10); + out[i] = Number.isNaN(n) ? 0 : n; + } + return out; } /** * A 2D rendering context for Canvas */ +type CanvasLineCap = "butt" | "round" | "square"; +type CanvasLineJoin = "miter" | "round" | "bevel"; +type CanvasTextAlign = "start" | "end" | "left" | "right" | "center"; +type CanvasTextBaseline = + | "top" + | "hanging" + | "middle" + | "alphabetic" + | "ideographic" + | "bottom"; +type CanvasDirection = "ltr" | "rtl" | "inherit"; +type ImageSmoothingQuality = "low" | "medium" | "high"; + class CanvasRenderingContext2D { #rid: number; + #lineDashOffset: number = 0; + #imageSmoothingEnabled: boolean = true; + #imageSmoothingQuality: ImageSmoothingQuality = "low"; + #filter: string = "none"; + // Per-context bookkeeping so `fillStyle = grad; fillStyle === grad` is + // true for both `CanvasGradient` and `CanvasPattern`. The Rust side + // stores gradients and patterns as opaque numeric rids — without this + // cache, the TS getter has no way to distinguish a gradient rid from a + // pattern rid (the source of the collision bug at the old `mod.ts:300` + // TODO). Keyed by `rid`, value is the JS instance that was set. + #fillStyleInstances: Map = new Map(); + #strokeStyleInstances: Map = + new Map(); constructor(rid: number) { this.#rid = rid; @@ -296,8 +730,12 @@ class CanvasRenderingContext2D { get fillStyle(): string | CanvasGradient | CanvasPattern { const fillStyle = __andromeda__.internal_canvas_get_fill_style(this.#rid); if (typeof fillStyle === "number") { - // For now, assume all numbers are gradients - // TODO: Distinguish between gradients and patterns + // Resolve to the JS instance that was set via the setter. Falls + // back to a fresh CanvasGradient only if the rid has no recorded + // owner — preserves referential equality per the HTML spec and + // avoids the old gradient-vs-pattern collision. + const cached = this.#fillStyleInstances.get(fillStyle); + if (cached !== undefined) return cached; return new CanvasGradient(fillStyle); } return fillStyle as string; @@ -306,9 +744,11 @@ class CanvasRenderingContext2D { set fillStyle(value: string | CanvasGradient | CanvasPattern) { if (typeof value === "string") { __andromeda__.internal_canvas_set_fill_style(this.#rid, value); - } else { - __andromeda__.internal_canvas_set_fill_style(this.#rid, value[_fillId]); + return; } + const rid = value[_fillId]; + this.#fillStyleInstances.set(rid, value); + __andromeda__.internal_canvas_set_fill_style(this.#rid, rid); } /** * Gets or sets the current stroke style for drawing operations. @@ -320,8 +760,8 @@ class CanvasRenderingContext2D { this.#rid, ); if (typeof strokeStyle === "number") { - // For now, assume all numbers are gradients - // TODO: Distinguish between gradients and patterns + const cached = this.#strokeStyleInstances.get(strokeStyle); + if (cached !== undefined) return cached; return new CanvasGradient(strokeStyle); } return strokeStyle as string; @@ -330,61 +770,160 @@ class CanvasRenderingContext2D { set strokeStyle(value: string | CanvasGradient | CanvasPattern) { if (typeof value === "string") { __andromeda__.internal_canvas_set_stroke_style(this.#rid, value); - } else { - // @ts-ignore - internal_canvas_set_stroke_style accepts numbers for gradients/patterns - __andromeda__.internal_canvas_set_stroke_style(this.#rid, value[_fillId]); + return; } + const rid = value[_fillId]; + this.#strokeStyleInstances.set(rid, value); + // @ts-ignore - internal_canvas_set_stroke_style accepts numbers for gradients/patterns + __andromeda__.internal_canvas_set_stroke_style(this.#rid, rid); } /** * Gets or sets the line width for drawing operations. + * Default is 1. */ get lineWidth(): number { return __andromeda__.internal_canvas_get_line_width(this.#rid); } set lineWidth(value: number) { + if (!Number.isFinite(value) || value <= 0) return; __andromeda__.internal_canvas_set_line_width(this.#rid, value); } /** - * Sets the line dash pattern. Accepts an array of numbers or a JSON string. + * Gets or sets the line cap style. One of "butt", "round", "square". + * Default is "butt". + */ + get lineCap(): CanvasLineCap { + return __andromeda__.internal_canvas_get_line_cap( + this.#rid, + ) as CanvasLineCap; + } + + set lineCap(value: CanvasLineCap) { + if (value !== "butt" && value !== "round" && value !== "square") return; + __andromeda__.internal_canvas_set_line_cap(this.#rid, value); + } + + /** + * Gets or sets the line join style. One of "miter", "round", "bevel". + * Default is "miter". + */ + get lineJoin(): CanvasLineJoin { + return __andromeda__.internal_canvas_get_line_join( + this.#rid, + ) as CanvasLineJoin; + } + + set lineJoin(value: CanvasLineJoin) { + if (value !== "miter" && value !== "round" && value !== "bevel") return; + __andromeda__.internal_canvas_set_line_join(this.#rid, value); + } + + /** + * Gets or sets the miter limit ratio. + * Default is 10. */ - setLineDash(segments: number[] | string, offset?: number): void { + get miterLimit(): number { + return __andromeda__.internal_canvas_get_miter_limit(this.#rid); + } + + set miterLimit(value: number) { + if (!Number.isFinite(value) || value <= 0) return; + __andromeda__.internal_canvas_set_miter_limit(this.#rid, value); + } + + /** + * Sets the line dash pattern per the HTML Canvas spec: a single sequence + * of non-negative, finite numbers. If an odd number of segments is given, + * the sequence is duplicated per spec. + * + * `lineDashOffset` is preserved across calls. + */ + setLineDash(segments: number[]): void { + if (!Array.isArray(segments)) return; + for (const n of segments) { + if (typeof n !== "number" || !Number.isFinite(n) || n < 0) return; + } + const normalized = + segments.length % 2 === 1 ? segments.concat(segments) : segments; __andromeda__.internal_canvas_set_line_dash( this.#rid, - segments, - offset ?? 0, + normalized, + this.#lineDashOffset, ); } /** - * Gets the line dash pattern as [segments, offset]. - * The runtime returns a JSON string; parse it here and return a tuple. + * Returns the current line dash pattern as a sequence of numbers per spec. */ - getLineDash(): [number[], number] { + getLineDash(): number[] { const json = __andromeda__.internal_canvas_get_line_dash(this.#rid); try { const info = JSON.parse(json); - return [info.dash || [], info.offset || 0]; + if (Array.isArray(info)) return info.slice(); + return Array.isArray(info?.dash) ? info.dash.slice() : []; } catch (_e) { if (typeof json === "string" && json.indexOf(",") !== -1) { - const parts = json.split(",").map(s => parseFloat(s.trim())).filter(n => - !Number.isNaN(n) - ); - return [parts, 0]; + return json + .split(",") + .map((s) => parseFloat(s.trim())) + .filter((n) => !Number.isNaN(n)); } - return [[], 0]; + return []; } } get lineDashOffset(): number { - const info = this.getLineDash(); - return info[1]; + return this.#lineDashOffset; } set lineDashOffset(value: number) { - const info = this.getLineDash(); - this.setLineDash(info[0], value); + if (!Number.isFinite(value)) return; + this.#lineDashOffset = value; + // Re-apply the current segments with the new offset so existing renderer + // code (which reads offset from the same setter) stays in sync. + const segments = this.getLineDash(); + __andromeda__.internal_canvas_set_line_dash(this.#rid, segments, value); + } + + /** + * Gets or sets whether image smoothing is enabled. + * Default is true. + * + * Currently stored JS-side only; wiring to the GPU sampler is a follow-up. + */ + get imageSmoothingEnabled(): boolean { + return this.#imageSmoothingEnabled; + } + + set imageSmoothingEnabled(value: boolean) { + this.#imageSmoothingEnabled = !!value; + } + + /** + * Gets or sets image smoothing quality. One of "low", "medium", "high". + * Default is "low". + */ + get imageSmoothingQuality(): ImageSmoothingQuality { + return this.#imageSmoothingQuality; + } + + set imageSmoothingQuality(value: ImageSmoothingQuality) { + if (value !== "low" && value !== "medium" && value !== "high") return; + this.#imageSmoothingQuality = value; + } + + /** + * Gets or sets the current CSS filter string. + * Default is "none". Currently stored but not applied in the renderer. + */ + get filter(): string { + return this.#filter; + } + + set filter(value: string) { + this.#filter = typeof value === "string" ? value : "none"; } /** @@ -433,7 +972,7 @@ class CanvasRenderingContext2D { } /** - * Creates an arc on the canvas. + * Adds a circular arc to the current path. */ arc( x: number, @@ -441,7 +980,23 @@ class CanvasRenderingContext2D { radius: number, startAngle: number, endAngle: number, + counterclockwise?: boolean, ): void { + if ( + !Number.isFinite(x) || + !Number.isFinite(y) || + !Number.isFinite(radius) || + !Number.isFinite(startAngle) || + !Number.isFinite(endAngle) + ) { + return; + } + if (radius < 0) { + throw new DOMException( + `The radius provided (${radius}) is negative.`, + "IndexSizeError", + ); + } __andromeda__.internal_canvas_arc( this.#rid, x, @@ -449,19 +1004,14 @@ class CanvasRenderingContext2D { radius, startAngle, endAngle, + !!counterclockwise, ); } /** * Creates an arc to the canvas. */ - arcTo( - x1: number, - y1: number, - x2: number, - y2: number, - radius: number, - ): void { + arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): void { __andromeda__.internal_canvas_arc_to(this.#rid, x1, y1, x2, y2, radius); } @@ -569,15 +1119,18 @@ class CanvasRenderingContext2D { image: ImageBitmap, repetition: string | null, ): CanvasPattern | null { - const imageRid = image["#rid" as keyof ImageBitmap] as number; + const imageRid = + typeof (image as unknown as { __getRid?: () => number }).__getRid === + "function" + ? (image as unknown as { __getRid: () => number }).__getRid() + : (image["#rid" as keyof ImageBitmap] as number); if (typeof imageRid !== "number") { return null; } // Normalize repetition parameter - const rep = repetition === null || repetition === "" ? - "repeat" : - repetition; + const rep = + repetition === null || repetition === "" ? "repeat" : repetition; // Validate repetition value if (!["repeat", "repeat-x", "repeat-y", "no-repeat"].includes(rep)) { @@ -627,16 +1180,30 @@ class CanvasRenderingContext2D { } /** - * Fills the current path with the current fill style. + * Fills the current path or the given Path2D. */ - fill(): void { + fill(pathOrRule?: Path2D | CanvasFillRule, _fillRule?: CanvasFillRule): void { + if (isPath2D(pathOrRule)) { + __andromeda__.internal_canvas_fill_path2d( + this.#rid, + (pathOrRule as Path2D).getRid(), + ); + return; + } __andromeda__.internal_canvas_fill(this.#rid); } /** - * Strokes the current path with the current stroke style. + * Strokes the current path or the given Path2D. */ - stroke(): void { + stroke(path?: Path2D): void { + if (isPath2D(path)) { + __andromeda__.internal_canvas_stroke_path2d( + this.#rid, + (path as Path2D).getRid(), + ); + return; + } __andromeda__.internal_canvas_stroke(this.#rid); } @@ -687,15 +1254,22 @@ class CanvasRenderingContext2D { y: number, width: number, height: number, - radius: number, + radii?: + | number + | { x: number; y: number } + | Array, ): void { + const [tl, tr, br, bl] = normalizeRoundRectRadii(radii); __andromeda__.internal_canvas_round_rect( this.#rid, x, y, width, height, - radius, + tl, + tr, + br, + bl, ); } @@ -713,6 +1287,51 @@ class CanvasRenderingContext2D { __andromeda__.internal_canvas_restore(this.#rid); } + /** + * Resets the rendering context to its default state per the Offscreen Canvas + */ + reset(): void { + __andromeda__.internal_canvas_reset_bitmap(this.#rid); + __andromeda__.internal_canvas_reset_state_stack(this.#rid); + this.resetTransform(); + this.beginPath(); + __andromeda__.internal_canvas_set_fill_style(this.#rid, "#000000"); + __andromeda__.internal_canvas_set_stroke_style(this.#rid, "#000000"); + __andromeda__.internal_canvas_set_line_width(this.#rid, 1); + __andromeda__.internal_canvas_set_line_cap(this.#rid, "butt"); + __andromeda__.internal_canvas_set_line_join(this.#rid, "miter"); + __andromeda__.internal_canvas_set_miter_limit(this.#rid, 10); + __andromeda__.internal_canvas_set_line_dash(this.#rid, [], 0); + __andromeda__.internal_canvas_set_global_alpha(this.#rid, 1); + __andromeda__.internal_canvas_set_global_composite_operation( + this.#rid, + "source-over", + ); + __andromeda__.internal_canvas_set_shadow_blur(this.#rid, 0); + __andromeda__.internal_canvas_set_shadow_color( + this.#rid, + "rgba(0, 0, 0, 0)", + ); + __andromeda__.internal_canvas_set_shadow_offset_x(this.#rid, 0); + __andromeda__.internal_canvas_set_shadow_offset_y(this.#rid, 0); + __andromeda__.internal_canvas_set_font(this.#rid, "10px sans-serif"); + __andromeda__.internal_canvas_set_text_align(this.#rid, "start"); + __andromeda__.internal_canvas_set_text_baseline(this.#rid, "alphabetic"); + __andromeda__.internal_canvas_set_direction(this.#rid, "inherit"); + this.#lineDashOffset = 0; + this.#imageSmoothingEnabled = true; + this.#imageSmoothingQuality = "low"; + this.#filter = "none"; + } + + /** + * Returns whether the context is lost. Andromeda is a headless runtime + * and never loses its canvas context, so this always returns false. + */ + isContextLost(): boolean { + return false; + } + /** * Adds a rotation to the transformation matrix. * @param angle The rotation angle, clockwise in radians. @@ -754,17 +1373,45 @@ class CanvasRenderingContext2D { } /** - * Resets (overrides) the current transformation to the identity matrix, and then invokes transform(). + * Resets the current transformation to the identity matrix, then multiplies + * it by the given matrix. Per the HTML Canvas spec, accepts either six + * floats or a `DOMMatrix2DInit` dictionary / `DOMMatrix` instance. Calling + * with no arguments resets to identity. */ setTransform( - a: number, - b: number, - c: number, - d: number, - e: number, - f: number, + aOrMatrix?: number | DOMMatrix2DInit | DOMMatrixReadOnly | null, + b?: number, + c?: number, + d?: number, + e?: number, + f?: number, ): void { - __andromeda__.internal_canvas_set_transform(this.#rid, a, b, c, d, e, f); + if (aOrMatrix === undefined || aOrMatrix === null) { + __andromeda__.internal_canvas_reset_transform(this.#rid); + return; + } + if (typeof aOrMatrix === "number") { + __andromeda__.internal_canvas_set_transform( + this.#rid, + aOrMatrix, + b!, + c!, + d!, + e!, + f!, + ); + return; + } + const m = parseMatrixInit(aOrMatrix); + __andromeda__.internal_canvas_set_transform( + this.#rid, + m.a, + m.b, + m.c, + m.d, + m.e, + m.f, + ); } /** @@ -775,18 +1422,19 @@ class CanvasRenderingContext2D { } /** - * Returns the current transformation matrix as an object. + * Returns the current transformation matrix as a `DOMMatrix` */ - getTransform(): { - a: number; - b: number; - c: number; - d: number; - e: number; - f: number; - } { + getTransform(): DOMMatrix { const json = __andromeda__.internal_canvas_get_transform(this.#rid); - return JSON.parse(json); + const parsed = JSON.parse(json) as DOMMatrix2DInit; + return new DOMMatrix([ + parsed.a ?? 1, + parsed.b ?? 0, + parsed.c ?? 0, + parsed.d ?? 1, + parsed.e ?? 0, + parsed.f ?? 0, + ]); } /** @@ -884,34 +1532,54 @@ class CanvasRenderingContext2D { } /** - * Determines whether the specified point is contained in the given path. + * Determines whether the specified point is in the current subpath + * or in the given Path2D. */ isPointInPath( - path: Path2D, - x: number, - y: number, + pathOrX: Path2D | number, + xOrY: number, + yOrRule?: number | CanvasFillRule, fillRule?: CanvasFillRule, ): boolean { - const rule = fillRule || "nonzero"; - return __andromeda__.internal_canvas_is_point_in_path( - path.getRid(), - x, - y, + if (isPath2D(pathOrX)) { + const rule = + (typeof yOrRule === "string" ? yOrRule : fillRule) || "nonzero"; + return __andromeda__.internal_canvas_is_point_in_path( + (pathOrX as Path2D).getRid(), + xOrY, + (yOrRule as number) ?? 0, + rule, + ); + } + const rule = ( + typeof yOrRule === "string" ? yOrRule : "nonzero" + ) as CanvasFillRule; + return __andromeda__.internal_canvas_is_point_in_current_path( + this.#rid, + pathOrX as number, + xOrY, rule, ); } /** - * Determines whether the specified point is inside the area contained by the stroking of a path. + * Determines whether the specified point is inside the stroked area + * of the current subpath or of the given Path2D. */ - isPointInStroke(path: Path2D, x: number, y: number): boolean { - // Use current line width - const lineWidth = this.lineWidth; - return __andromeda__.internal_canvas_is_point_in_stroke( - path.getRid(), - x, - y, - lineWidth, + isPointInStroke(pathOrX: Path2D | number, xOrY: number, y?: number): boolean { + if (isPath2D(pathOrX)) { + return __andromeda__.internal_canvas_is_point_in_stroke( + (pathOrX as Path2D).getRid(), + xOrY, + y ?? 0, + this.lineWidth, + ); + } + return __andromeda__.internal_canvas_is_point_in_current_stroke( + this.#rid, + pathOrX as number, + xOrY, + this.lineWidth, ); } @@ -922,10 +1590,7 @@ class CanvasRenderingContext2D { * - drawImage(image, dx, dy, dWidth, dHeight) * - drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight) */ - drawImage( - image: ImageBitmap, - ...args: number[] - ): void { + drawImage(image: ImageBitmap, ...args: number[]): void { const imageRid = image["#rid" as keyof ImageBitmap] as number; if (args.length === 2) { @@ -981,11 +1646,40 @@ class CanvasRenderingContext2D { } /** - * Creates a new, blank ImageData object with the specified dimensions. + * Creates a new blank ImageData object per the HTML Canvas spec. */ - createImageData(width: number, height: number): ImageData { - const rid = __andromeda__.internal_canvas_create_image_data(width, height); - return new ImageData(rid, width, height); + createImageData( + widthOrImageData: number | ImageData, + height?: number, + ): ImageData { + if (typeof widthOrImageData === "number") { + if (height === undefined || !Number.isFinite(height)) { + throw new TypeError( + "createImageData: height is required when width is a number", + ); + } + const rid = __andromeda__.internal_canvas_create_image_data( + widthOrImageData, + height, + ); + return new ImageData( + _internalImageDataCtor, + rid, + widthOrImageData, + height, + ); + } + if (!(widthOrImageData instanceof ImageData)) { + throw new TypeError( + "createImageData: expected a number or ImageData instance.", + ); + } + const src = widthOrImageData; + const rid = __andromeda__.internal_canvas_create_image_data( + src.width, + src.height, + ); + return new ImageData(_internalImageDataCtor, rid, src.width, src.height); } /** @@ -999,23 +1693,77 @@ class CanvasRenderingContext2D { sw, sh, ); - return new ImageData(rid, sw, sh); + return new ImageData(_internalImageDataCtor, rid, sw, sh); } /** * Paints data from an ImageData object onto the canvas. */ - putImageData(imageData: ImageData, dx: number, dy: number): void { - const imageRid = imageData["#rid" as keyof ImageData] as number; + putImageData( + imageData: ImageData, + dx: number, + dy: number, + dirtyX?: number, + dirtyY?: number, + dirtyWidth?: number, + dirtyHeight?: number, + ): void { + const imageRid = ( + imageData as unknown as { __syncAndGetRid(): number } + ).__syncAndGetRid(); + if (dirtyX !== undefined) { + // Normalize: negative widths flip and reanchor per spec. + let dx0 = dirtyX; + let dy0 = dirtyY ?? 0; + let dw = dirtyWidth ?? imageData.width; + let dh = dirtyHeight ?? imageData.height; + if (dw < 0) { + dx0 += dw; + dw = -dw; + } + if (dh < 0) { + dy0 += dh; + dh = -dh; + } + // Clamp to the image's own bounds. + if (dx0 < 0) { + dw += dx0; + dx0 = 0; + } + if (dy0 < 0) { + dh += dy0; + dy0 = 0; + } + if (dx0 + dw > imageData.width) dw = imageData.width - dx0; + if (dy0 + dh > imageData.height) dh = imageData.height - dy0; + if (dw <= 0 || dh <= 0) return; + const coversWhole = + dx0 === 0 && + dy0 === 0 && + dw === imageData.width && + dh === imageData.height; + if (!coversWhole) { + throw new DOMException( + "putImageData dirty-rect form is not yet supported by the Andromeda renderer; omit the dirty-rect arguments or pass the full image bounds.", + "NotSupportedError", + ); + } + } __andromeda__.internal_canvas_put_image_data(this.#rid, imageRid, dx, dy); } /** - * Turns the given path into the current clipping region. + * Turns the current path (or the given Path2D) into the clipping region. */ - clip(path: Path2D, fillRule?: CanvasFillRule): void { - const _rule = fillRule || "nonzero"; - __andromeda__.internal_canvas_clip(this.#rid, path.getRid()); + clip(pathOrRule?: Path2D | CanvasFillRule, _fillRule?: CanvasFillRule): void { + if (isPath2D(pathOrRule)) { + __andromeda__.internal_canvas_clip_path2d( + this.#rid, + (pathOrRule as Path2D).getRid(), + ); + return; + } + __andromeda__.internal_canvas_clip_current(this.#rid); } } @@ -1028,9 +1776,22 @@ class CanvasGradient { this[_fillId] = rid; } /** - * Adds a new color stop to a given canvas gradient. + * Adds a new color stop to the gradient. + * Throws `IndexSizeError` if offset is not in the range [0, 1] per spec. */ addColorStop(offset: number, color: string) { + if (!Number.isFinite(offset) || offset < 0 || offset > 1) { + throw new DOMException( + `The offset provided (${offset}) is outside the range [0, 1].`, + "IndexSizeError", + ); + } + if (typeof color !== "string") { + throw new DOMException( + `The color provided is not a string.`, + "SyntaxError", + ); + } __andromeda__.internal_canvas_gradient_add_color_stop( this[_fillId], offset, @@ -1051,26 +1812,111 @@ class CanvasPattern { /** * Sets the transformation matrix that will be used when rendering the pattern. - * Note: This is currently not implemented in the renderer. + * Accepts `DOMMatrix`, `DOMMatrix2DInit`, or omitted (resets to identity). */ - setTransform(_transform?: object): void { - // TODO: Implement pattern transformation - // This would require passing the transformation matrix to the renderer - } + setTransform(transform?: DOMMatrix2DInit | DOMMatrixReadOnly | null): void { + if (transform === undefined || transform === null) { + this.#transform = undefined; + return; + } + const m = parseMatrixInit(transform); + this.#transform = m; + } + + #transform: + | { + a: number; + b: number; + c: number; + d: number; + e: number; + f: number; + } + | undefined; } /** * Represents the underlying pixel data of an area of a canvas element. */ +const _internalImageDataCtor = Symbol("[[internalImageDataCtor]]"); + class ImageData { #rid: number; #width: number; #height: number; + #userData: Uint8ClampedArray | undefined; + #cached: Uint8ClampedArray | undefined; - constructor(rid: number, width: number, height: number) { - this.#rid = rid; - this.#width = width; - this.#height = height; + /** + * Constructs an ImageData object. + */ + constructor( + firstArg: number | Uint8ClampedArray | typeof _internalImageDataCtor, + widthOrHeightOrRid?: number, + heightOrWidth?: number, + height?: number, + ) { + if (firstArg === _internalImageDataCtor) { + this.#rid = widthOrHeightOrRid as number; + this.#width = heightOrWidth as number; + this.#height = height as number; + return; + } + if (firstArg instanceof Uint8ClampedArray) { + const data = firstArg; + const w = widthOrHeightOrRid as number; + const h = heightOrWidth ?? data.length / (w * 4); + if (!Number.isInteger(w) || w <= 0 || !Number.isInteger(h) || h <= 0) { + throw new DOMException( + "ImageData constructor: width and height must be positive integers.", + "IndexSizeError", + ); + } + if (data.length !== w * h * 4) { + throw new DOMException( + `ImageData constructor: buffer length ${data.length} does not match width*height*4 = ${w * h * 4}.`, + "InvalidStateError", + ); + } + const rid = __andromeda__.internal_canvas_create_image_data(w, h); + this.#rid = rid; + this.#width = w; + this.#height = h; + this.#userData = data; + syncImageDataToRust(rid, data); + return; + } + if ( + typeof firstArg === "number" && + typeof widthOrHeightOrRid === "number" + ) { + if (heightOrWidth !== undefined) { + throw new TypeError( + "ImageData constructor: too many arguments for the (width, height) form.", + ); + } + const width = firstArg; + const h = widthOrHeightOrRid; + if ( + !Number.isInteger(width) || + width <= 0 || + !Number.isInteger(h) || + h <= 0 + ) { + throw new DOMException( + "ImageData constructor: width and height must be positive integers.", + "IndexSizeError", + ); + } + const rid = __andromeda__.internal_canvas_create_image_data(width, h); + this.#rid = rid; + this.#width = width; + this.#height = h; + return; + } + throw new TypeError( + "ImageData constructor: expected (width, height) or (Uint8ClampedArray, width, height?).", + ); } /** @@ -1088,15 +1934,67 @@ class ImageData { } /** - * A Uint8ClampedArray representing a one-dimensional array containing the data in RGBA order. + * Pixel bytes in RGBA order, one byte per channel per pixel. */ - get data(): string { - // Returns JSON string representation for now - // TODO: Return proper Uint8ClampedArray when TypedArray support is added - return __andromeda__.internal_image_data_get_data(this.#rid); + get data(): Uint8ClampedArray { + if (this.#userData !== undefined) return this.#userData; + if (this.#cached === undefined) { + this.#cached = decodeImageDataBytes( + __andromeda__.internal_image_data_get_data(this.#rid), + this.#width * this.#height * 4, + ); + } + return this.#cached; + } + + __syncAndGetRid(): number { + if (this.#userData !== undefined) { + syncImageDataToRust(this.#rid, this.#userData); + } else if (this.#cached !== undefined) { + syncImageDataToRust(this.#rid, this.#cached); + } + return this.#rid; } } +// TODO: implement into nova's JS API a more efficient way to transfer large binary data buffers into Rust, to avoid this O(n) stringification step on every sync. +function syncImageDataToRust(rid: number, data: Uint8ClampedArray): void { + const parts = new Array(data.length); + for (let i = 0; i < data.length; i++) parts[i] = data[i].toString(); + __andromeda__.internal_image_data_set_data(rid, parts.join(",")); +} + +/** + * Parse the byte representation returned by `internal_image_data_get_data` + * into a fixed-length `Uint8ClampedArray`. + */ +function decodeImageDataBytes( + raw: unknown, + expectedLength: number, +): Uint8ClampedArray { + const out = new Uint8ClampedArray(expectedLength); + if (typeof raw !== "string" || raw.length === 0) return out; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + const n = Math.min(parsed.length, expectedLength); + for (let i = 0; i < n; i++) out[i] = parsed[i] | 0; + return out; + } + } catch (_) { + // Fall through to CSV parsing below. + } + const stripped = + raw.startsWith("[") && raw.endsWith("]") ? raw.slice(1, -1) : raw; + let i = 0; + for (const piece of stripped.split(",")) { + if (i >= expectedLength) break; + const n = parseInt(piece, 10); + if (!Number.isNaN(n)) out[i++] = n; + } + return out; +} + /** * Represents the dimensions of a piece of text in the canvas. * Returned by CanvasRenderingContext2D.measureText(). @@ -1150,5 +2048,8 @@ Object.assign(globalThis, { OffscreenCanvas, ImageData, CanvasPattern, + CanvasGradient, TextMetrics, + DOMMatrix, + DOMMatrixReadOnly, }); diff --git a/crates/runtime/src/ext/canvas/renderer/render.rs b/crates/runtime/src/ext/canvas/renderer/render.rs index ec4d891b..cb66c8b4 100644 --- a/crates/runtime/src/ext/canvas/renderer/render.rs +++ b/crates/runtime/src/ext/canvas/renderer/render.rs @@ -12,7 +12,9 @@ pub struct Renderer { pub device: wgpu::Device, pub queue: wgpu::Queue, pub pipeline: wgpu::RenderPipeline, + /// MSAA-sampled color target — what every render pass writes to. pub background: wgpu::Texture, + pub resolve_target: wgpu::Texture, pub stencil_texture: wgpu::Texture, pub default_sampler: wgpu::Sampler, pub default_texture: wgpu::Texture, @@ -23,10 +25,14 @@ pub struct Renderer { pub texture_cache: std::collections::HashMap, /// Current clipping path pub clip_path: Option, + pub flip_v_next: bool, } const U32_SIZE: u32 = std::mem::size_of::() as u32; +/// Multisample count for anti-aliasing. +pub const MSAA_SAMPLES: u32 = 4; + #[allow(dead_code)] impl Renderer { pub fn create_stroke_data( @@ -69,45 +75,32 @@ impl Renderer { &mut self, rect: Rect, render_state: &RenderState, - stroke_color: [f32; 4], + _stroke_color: [f32; 4], stroke_width: f32, ) { - let stroke_data = self.create_stroke_data(render_state, stroke_color, stroke_width); - // Apply transformation to all four corners - let top_left = transform_point(&rect.start, &render_state.transform); - let top_right = transform_point( - &Point { + let path = vec![ + Point { + x: rect.start.x, + y: rect.start.y, + }, + Point { x: rect.end.x, y: rect.start.y, }, - &render_state.transform, - ); - let bottom_right = transform_point(&rect.end, &render_state.transform); - let bottom_left = transform_point( - &Point { + Point { + x: rect.end.x, + y: rect.end.y, + }, + Point { x: rect.start.x, y: rect.end.y, }, - &render_state.transform, - ); - - let tl = translate_coords(&top_left, &self.dimensions); - let tr = translate_coords(&top_right, &self.dimensions); - let br = translate_coords(&bottom_right, &self.dimensions); - let bl = translate_coords(&bottom_left, &self.dimensions); - - let vertex = vec![ - (tl.0, tl.1), - (tr.0, tr.1), - (br.0, br.1), - (bl.0, bl.1), - (tl.0, tl.1), // close the loop + Point { + x: rect.start.x, + y: rect.start.y, + }, ]; - self.create_render_command(RenderData { - vertex, - fill_data: stroke_data, - length: 5, - }); + self.render_polyline(path, render_state, stroke_width as f64); } pub fn new( device: wgpu::Device, @@ -215,7 +208,7 @@ impl Renderer { })], }), primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleStrip, + topology: wgpu::PrimitiveTopology::TriangleList, strip_index_format: None, front_face: wgpu::FrontFace::Ccw, cull_mode: None, @@ -246,7 +239,7 @@ impl Renderer { bias: wgpu::DepthBiasState::default(), }), multisample: wgpu::MultisampleState { - count: 1, + count: MSAA_SAMPLES, mask: !0, alpha_to_coverage_enabled: false, }, @@ -254,7 +247,22 @@ impl Renderer { }); let background = device.create_texture(&wgpu::TextureDescriptor { - label: Some("Background"), + label: Some("Background (MSAA)"), + dimension: wgpu::TextureDimension::D2, + format, + mip_level_count: 1, + sample_count: MSAA_SAMPLES, + size: wgpu::Extent3d { + depth_or_array_layers: 1, + height: dimensions.height, + width: dimensions.width, + }, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + + let resolve_target = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Background (Resolve)"), dimension: wgpu::TextureDimension::D2, format, mip_level_count: 1, @@ -268,13 +276,14 @@ impl Renderer { view_formats: &[], }); - // Create stencil texture for clipping operations + // Stencil texture sample count must match the color attachment's + // sample count for every render pass that uses both. let stencil_texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("Stencil"), dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Depth24PlusStencil8, mip_level_count: 1, - sample_count: 1, + sample_count: MSAA_SAMPLES, size: wgpu::Extent3d { depth_or_array_layers: 1, height: dimensions.height, @@ -338,6 +347,7 @@ impl Renderer { queue, pipeline, background, + resolve_target, stencil_texture, default_sampler, default_texture, @@ -345,6 +355,7 @@ impl Renderer { commands: vec![], texture_cache: std::collections::HashMap::new(), clip_path: None, + flip_v_next: false, } } @@ -359,6 +370,7 @@ impl Renderer { } { + let resolve_view = self.resolve_target.create_view(&Default::default()); let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: None, color_attachments: &[Some(wgpu::RenderPassColorAttachment { @@ -371,9 +383,9 @@ impl Renderer { b: 1.0, a: 1.0, }), - store: wgpu::StoreOp::Store, + store: wgpu::StoreOp::Discard, }, - resolve_target: None, + resolve_target: Some(&resolve_view), })], depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { view: &self.stencil_texture.create_view(&Default::default()), @@ -426,7 +438,7 @@ impl Renderer { wgpu::TexelCopyTextureInfo { aspect: wgpu::TextureAspect::All, mip_level: 0, - texture: &self.background, + texture: &self.resolve_target, origin: wgpu::Origin3d::ZERO, }, wgpu::TexelCopyBufferInfo { @@ -572,6 +584,16 @@ impl Renderer { } pub fn create_fill_data(&self, render_state: &RenderState) -> FillData { + let tx = |p: (f32, f32)| -> (f32, f32) { + let tp = transform_point( + &Point { + x: p.0 as f64, + y: p.1 as f64, + }, + &render_state.transform, + ); + (tp.x as f32, tp.y as f32) + }; match &render_state.fill_style { FillStyle::Color { r, g, b, a } => FillData { uniforms: Uniforms { @@ -604,8 +626,8 @@ impl Renderer { FillStyle::LinearGradient(gradient) => FillData { uniforms: Uniforms { color: [0.0, 0.0, 0.0, 0.0], - gradient_start: gradient.start, - gradient_end: gradient.end, + gradient_start: tx(gradient.start), + gradient_end: tx(gradient.end), fill_style: 1, global_alpha: render_state.global_alpha, radius_start: 0.0, @@ -632,8 +654,8 @@ impl Renderer { FillStyle::RadialGradient(gradient) => FillData { uniforms: Uniforms { color: [0.0, 0.0, 0.0, 0.0], - gradient_start: gradient.start, - gradient_end: gradient.end, + gradient_start: tx(gradient.start), + gradient_end: tx(gradient.end), fill_style: 2, global_alpha: render_state.global_alpha, radius_start: gradient.start_radius, @@ -660,7 +682,7 @@ impl Renderer { FillStyle::ConicGradient(gradient) => FillData { uniforms: Uniforms { color: [0.0, 0.0, 0.0, 0.0], - gradient_start: gradient.center, + gradient_start: tx(gradient.center), gradient_end: (0.0, 0.0), fill_style: 3, global_alpha: render_state.global_alpha, @@ -727,28 +749,83 @@ impl Renderer { } pub async fn save_as_png(&mut self, path: &str) -> Result<(), Box> { - // Extract pixel data from GPU - let pixel_data = self.create_bitmap().await; + let img = self.snapshot_as_image().await?; + img.save(path)?; + Ok(()) + } - // Convert from BGRA to RGBA (wgpu typically uses BGRA format) - let mut rgba_data = Vec::new(); + pub async fn snapshot_as_image( + &mut self, + ) -> Result> { + let pixel_data = self.create_bitmap().await; + // wgpu typically produces BGRA on swapchain textures; convert to + // RGBA and unpremultiply in the same pass. + let mut rgba_data = Vec::with_capacity(pixel_data.len()); for chunk in pixel_data.chunks(4) { - if chunk.len() == 4 { - // Convert BGRA -> RGBA - rgba_data.push(chunk[2]); // R - rgba_data.push(chunk[1]); // G - rgba_data.push(chunk[0]); // B - rgba_data.push(chunk[3]); // A + if chunk.len() != 4 { + continue; + } + let b = chunk[0] as u32; + let g = chunk[1] as u32; + let r = chunk[2] as u32; + let a = chunk[3] as u32; + if a == 0 { + // Spec: transparent pixels have no defined rgb — zero. + rgba_data.extend_from_slice(&[0, 0, 0, 0]); + } else if a == 255 { + // Fast path for opaque: no division needed. + rgba_data.extend_from_slice(&[r as u8, g as u8, b as u8, 255]); + } else { + // Unpremultiply with round-to-nearest. Clamp at 255 so + // accumulated floating-point error in the blend doesn't + // push a channel over the top. + let unr = ((r * 255 + a / 2) / a).min(255) as u8; + let ung = ((g * 255 + a / 2) / a).min(255) as u8; + let unb = ((b * 255 + a / 2) / a).min(255) as u8; + rgba_data.extend_from_slice(&[unr, ung, unb, a as u8]); } } + image::RgbaImage::from_raw(self.dimensions.width, self.dimensions.height, rgba_data) + .ok_or_else(|| "Failed to create image from pixel data".into()) + } - // Save as PNG using the image crate - let img = - image::RgbaImage::from_raw(self.dimensions.width, self.dimensions.height, rgba_data) - .ok_or("Failed to create image from pixel data")?; + /// Encode the current framebuffer as a PNG into an in-memory buffer. + pub async fn encode_as_png(&mut self) -> Result, Box> { + use image::ImageEncoder; + let img = self.snapshot_as_image().await?; + let mut out = Vec::new(); + image::codecs::png::PngEncoder::new(&mut out).write_image( + img.as_raw(), + img.width(), + img.height(), + image::ExtendedColorType::Rgba8, + )?; + Ok(out) + } - img.save(path)?; - Ok(()) + /// Encode the current framebuffer as a JPEG into an in-memory buffer. + /// `quality` is in [1..=100]; caller clamps before passing. + pub async fn encode_as_jpeg( + &mut self, + quality: u8, + ) -> Result, Box> { + let img = self.snapshot_as_image().await?; + let mut rgb = Vec::with_capacity((img.width() * img.height() * 3) as usize); + for pixel in img.pixels() { + let a = pixel[3] as u16; + rgb.push(((pixel[0] as u16 * a + 127) / 255) as u8); + rgb.push(((pixel[1] as u16 * a + 127) / 255) as u8); + rgb.push(((pixel[2] as u16 * a + 127) / 255) as u8); + } + let mut out = Vec::new(); + let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality); + encoder.encode( + &rgb, + img.width(), + img.height(), + image::ExtendedColorType::Rgb8, + )?; + Ok(out) } pub fn render_rect(&mut self, rect: Rect, render_state: &RenderState) { @@ -785,6 +862,11 @@ impl Renderer { let bl = translate_coords(&bottom_left, &self.dimensions); let br = translate_coords(&bottom_right, &self.dimensions); + let rect_triangles = + |tl: (f32, f32), bl: (f32, f32), tr: (f32, f32), br: (f32, f32)| -> Vec<(f32, f32)> { + vec![tl, bl, tr, bl, br, tr] + }; + // Render shadow first if enabled if is_shadow_enabled(render_state) { let shadow_fill_data = create_shadow_fill_data(render_state, &fill_data); @@ -793,43 +875,69 @@ impl Renderer { let offset_y = -(render_state.shadow_offset_y / self.dimensions.height as f64) as f32 * 2.0; - let shadow_vertex = vec![ + let shadow_vertex = rect_triangles( (tl.0 + offset_x, tl.1 + offset_y), (bl.0 + offset_x, bl.1 + offset_y), (tr.0 + offset_x, tr.1 + offset_y), (br.0 + offset_x, br.1 + offset_y), - ]; + ); self.create_render_command_with_texture( RenderData { + length: shadow_vertex.len() as u32, vertex: shadow_vertex, fill_data: shadow_fill_data, - length: 4, }, None, ); } - let vertex = vec![(tl.0, tl.1), (bl.0, bl.1), (tr.0, tr.1), (br.0, br.1)]; - + let vertex = rect_triangles(tl, bl, tr, br); let texture_view = pattern_image_rid .and_then(|image_rid| self.texture_cache.get(&image_rid)) .map(|t| t.create_view(&Default::default())); self.create_render_command_with_texture_view( RenderData { + length: vertex.len() as u32, vertex, fill_data, - length: 4, }, texture_view, ); } + /// Fill a polygon given its perimeter. pub fn render_polygon(&mut self, polygon: Path, render_state: &RenderState) { + if polygon.len() < 3 { + return; + } + let triangles = if is_convex_polygon(&polygon) { + let mut out: Path = Vec::with_capacity(3 * (polygon.len() - 2)); + for i in 1..(polygon.len() - 1) { + out.push(polygon[0].clone()); + out.push(polygon[i].clone()); + out.push(polygon[i + 1].clone()); + } + out + } else { + earcut_triangulate(&polygon) + }; + self.render_triangles(triangles, render_state); + } + + /// Dispatch a pre-triangulated list of world-space triangles to the GPU. + pub fn render_triangles(&mut self, triangles: Path, render_state: &RenderState) { + debug_assert!( + triangles.len().is_multiple_of(3), + "render_triangles: length must be a multiple of 3" + ); + if triangles.is_empty() { + return; + } + let fill_data = self.create_fill_data(render_state); - // Check if we need a texture for pattern fill style let pattern_image_rid = if let FillStyle::Pattern { image_rid, .. } = &render_state.fill_style { Some(*image_rid) @@ -837,21 +945,16 @@ impl Renderer { None }; - let mut data = Vec::new(); - if let 0 = polygon.len() % 2 { - for i in 0..(polygon.len() / 2) { - data.push(&polygon[i]); - data.push(&polygon[polygon.len() - 1 - i]); - } - } else { - for i in 0..((polygon.len() - 1) / 2) { - data.push(&polygon[i]); - data.push(&polygon[polygon.len() - 1 - i]); - } - data.push(&polygon[(polygon.len() - 1) / 2]); - } + // Project once. + let projected: Vec = triangles + .iter() + .map(|p| { + let transformed = transform_point(p, &render_state.transform); + translate_coords(&transformed, &self.dimensions) + }) + .collect(); - // Render shadow first if enabled + // Shadow pass. if is_shadow_enabled(render_state) { let shadow_fill_data = create_shadow_fill_data(render_state, &fill_data); let offset_x = @@ -859,43 +962,30 @@ impl Renderer { let offset_y = -(render_state.shadow_offset_y / self.dimensions.height as f64) as f32 * 2.0; - let shadow_vertex: Vec = data + let shadow_vertex: Vec = projected .iter() - .map(|p| { - let transformed = transform_point(p, &render_state.transform); - let coords = translate_coords(&transformed, &self.dimensions); - (coords.0 + offset_x, coords.1 + offset_y) - }) + .map(|&(x, y)| (x + offset_x, y + offset_y)) .collect(); self.create_render_command_with_texture( RenderData { + length: shadow_vertex.len() as u32, vertex: shadow_vertex, fill_data: shadow_fill_data, - length: data.len() as u32, }, None, - ); // Shadows don't use textures - }; - // Apply transformation to each point before translating to clip space - let vertex = data - .iter() - .map(|point| { - let transformed = transform_point(point, &render_state.transform); - translate_coords(&transformed, &self.dimensions) - }) - .collect::>(); + ); + } - // Handle texture for patterns - create view before mutable borrow let texture_view = pattern_image_rid .and_then(|image_rid| self.texture_cache.get(&image_rid)) .map(|t| t.create_view(&Default::default())); self.create_render_command_with_texture_view( RenderData { - vertex, + length: projected.len() as u32, + vertex: projected, fill_data, - length: polygon.len() as u32, }, texture_view, ); @@ -1001,8 +1091,11 @@ impl Renderer { vertices.push(c.clone()); } - // Render the triangles - self.render_polygon(vertices, render_state); + // Vertices are already laid out as a triangle list (two triangles + // per stroke segment). Dispatch directly — do NOT route through + // `render_polygon`, which would fan-triangulate from the first + // vertex and turn multi-segment strokes into a fan shape. + self.render_triangles(vertices, render_state); } #[allow(dead_code)] @@ -1333,25 +1426,20 @@ impl Renderer { let br = translate_coords(&bottom_right, &self.dimensions); let bl = translate_coords(&bottom_left, &self.dimensions); - // Create vertex data with UV coordinates using triangle strip with 4 vertices - // Order for triangle strip: TL, BL, TR, BR - let vertex_positions = [ - (tl.0, tl.1), // Top-left - (bl.0, bl.1), // Bottom-left - (tr.0, tr.1), // Top-right - (br.0, br.1), // Bottom-right - ]; + let (tl_v, bl_v, tr_v, br_v) = if self.flip_v_next { + (v_end, v_start, v_end, v_start) + } else { + (v_start, v_end, v_start, v_end) + }; + self.flip_v_next = false; + let tl_uv = (u_start, tl_v); + let bl_uv = (u_start, bl_v); + let tr_uv = (u_end, tr_v); + let br_uv = (u_end, br_v); - // UV coordinates in same order: TL, BL, TR, BR - // Images are stored with origin at top-left, so we flip V coordinates - let uvs = [ - (u_start, v_end), // Top-left (V flipped) - (u_start, v_start), // Bottom-left (V flipped) - (u_end, v_end), // Top-right (V flipped) - (u_end, v_start), // Bottom-right (V flipped) - ]; + let vertex_positions = [tl, bl, tr, bl, br, tr]; + let uvs = [tl_uv, bl_uv, tr_uv, bl_uv, br_uv, tr_uv]; - // Manually create vertex data with UVs let mut vertex_data_with_uv = Vec::new(); for (i, pos) in vertex_positions.iter().enumerate() { vertex_data_with_uv.extend_from_slice(&pos.0.to_ne_bytes()); @@ -1366,8 +1454,12 @@ impl Renderer { gradient: vec![], }; - // Create a custom render command for images - self.create_image_render_command(vertex_data_with_uv, fill_data, 4, texture_view); + self.create_image_render_command( + vertex_data_with_uv, + fill_data, + vertex_positions.len() as u32, + texture_view, + ); } /// Create a render command specifically for images with UV coordinates @@ -1458,7 +1550,7 @@ impl Renderer { fn vs_main(@location(0) position: vec2f) -> @builtin(position) vec4f { return vec4f(position, 0.0, 1.0); } - + @fragment fn fs_main() -> @location(0) vec4f { return vec4f(0.0, 0.0, 0.0, 0.0); @@ -1537,7 +1629,7 @@ impl Renderer { bias: wgpu::DepthBiasState::default(), }), multisample: wgpu::MultisampleState { - count: 1, + count: MSAA_SAMPLES, mask: !0, alpha_to_coverage_enabled: false, }, @@ -1687,3 +1779,132 @@ pub fn translate_coords(point: &Point, dimensions: &Dimensions) -> (f32, f32) { let y = (point.y / (dimensions.height as f64) * -2.0 + 1.0) as f32; (x, y) } + +/// Signed area × 2 of a polygon (positive = CCW, negative = CW). +fn polygon_signed_area2(poly: &[Point]) -> f64 { + let n = poly.len(); + if n < 3 { + return 0.0; + } + let mut sum = 0.0; + for i in 0..n { + let a = &poly[i]; + let b = &poly[(i + 1) % n]; + sum += a.x * b.y - b.x * a.y; + } + sum +} + +/// True if every interior vertex of `poly` turns in the same direction. +fn is_convex_polygon(poly: &[Point]) -> bool { + let n = poly.len(); + if n < 4 { + return true; + } + let mut sign = 0i8; + for i in 0..n { + let a = &poly[i]; + let b = &poly[(i + 1) % n]; + let c = &poly[(i + 2) % n]; + let cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x); + if cross.abs() < 1e-9 { + continue; + } + let s: i8 = if cross > 0.0 { 1 } else { -1 }; + if sign == 0 { + sign = s; + } else if sign != s { + return false; + } + } + true +} + +/// Ear-clipping triangulation for a simple polygon (handles concave but not +/// self-intersecting). Normalizes winding to CCW, then repeatedly clips the +/// "pointiest" convex ear whose triangle contains no other vertex. +fn earcut_triangulate(poly: &[Point]) -> Path { + let n = poly.len(); + if n < 3 { + return Vec::new(); + } + // Work in CCW so the convex-vertex test is consistent. + let ccw = polygon_signed_area2(poly) >= 0.0; + let mut idx: Vec = if ccw { + (0..n).collect() + } else { + (0..n).rev().collect() + }; + + let cross = |a: &Point, b: &Point, c: &Point| -> f64 { + (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) + }; + let point_in_tri = |p: &Point, a: &Point, b: &Point, c: &Point| -> bool { + let d1 = cross(a, b, p); + let d2 = cross(b, c, p); + let d3 = cross(c, a, p); + let has_neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0; + let has_pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0; + !(has_neg && has_pos) + }; + + let mut out: Path = Vec::with_capacity(3 * (n - 2)); + // Bail-out counter avoids an infinite loop on degenerate/self-intersecting + // input (ear finder may fail to find a valid ear). + let mut guard = 2 * idx.len(); + while idx.len() > 3 && guard > 0 { + guard -= 1; + let m = idx.len(); + let mut clipped = false; + for i in 0..m { + let ia = idx[(i + m - 1) % m]; + let ib = idx[i]; + let ic = idx[(i + 1) % m]; + let a = &poly[ia]; + let b = &poly[ib]; + let c = &poly[ic]; + // Reflex vertex -> not an ear. + if cross(a, b, c) <= 0.0 { + continue; + } + // Some other vertex inside the candidate ear -> not an ear. + let mut contains_other = false; + for (j, &jidx) in idx.iter().enumerate() { + if j == i || j == (i + m - 1) % m || j == (i + 1) % m { + continue; + } + if point_in_tri(&poly[jidx], a, b, c) { + contains_other = true; + break; + } + } + if contains_other { + continue; + } + out.push(a.clone()); + out.push(b.clone()); + out.push(c.clone()); + idx.remove(i); + clipped = true; + break; + } + if !clipped { + // Fallback: degenerate input. Emit remaining fan so we draw + // something instead of nothing. + break; + } + } + if idx.len() == 3 { + out.push(poly[idx[0]].clone()); + out.push(poly[idx[1]].clone()); + out.push(poly[idx[2]].clone()); + } else if idx.len() > 3 { + // Fan fallback from the ear-clip bailout path. + for i in 1..(idx.len() - 1) { + out.push(poly[idx[0]].clone()); + out.push(poly[idx[i]].clone()); + out.push(poly[idx[i + 1]].clone()); + } + } + out +} diff --git a/crates/runtime/src/ext/canvas/renderer/shader.rs b/crates/runtime/src/ext/canvas/renderer/shader.rs index 1157ff5f..a4a7b193 100644 --- a/crates/runtime/src/ext/canvas/renderer/shader.rs +++ b/crates/runtime/src/ext/canvas/renderer/shader.rs @@ -72,11 +72,11 @@ fn rgb_to_hsl(rgb: vec3f) -> vec3f { let max_c = max(max(rgb.r, rgb.g), rgb.b); let min_c = min(min(rgb.r, rgb.g), rgb.b); let l = (max_c + min_c) * 0.5; - + if (max_c == min_c) { return vec3f(0.0, 0.0, l); } - + let d = max_c - min_c; var s: f32; if (l > 0.5) { @@ -84,7 +84,7 @@ fn rgb_to_hsl(rgb: vec3f) -> vec3f { } else { s = d / (max_c + min_c); } - + var h: f32; if (max_c == rgb.r) { h = (rgb.g - rgb.b) / d + select(0.0, 6.0, rgb.g < rgb.b); @@ -94,7 +94,7 @@ fn rgb_to_hsl(rgb: vec3f) -> vec3f { h = (rgb.r - rgb.g) / d + 4.0; } h = h / 6.0; - + return vec3f(h, s, l); } @@ -103,7 +103,7 @@ fn hsl_to_rgb(hsl: vec3f) -> vec3f { if (hsl.y == 0.0) { return vec3f(hsl.z, hsl.z, hsl.z); } - + var q: f32; if (hsl.z < 0.5) { q = hsl.z * (1.0 + hsl.y); @@ -111,11 +111,11 @@ fn hsl_to_rgb(hsl: vec3f) -> vec3f { q = hsl.z + hsl.y - hsl.z * hsl.y; } let p = 2.0 * hsl.z - q; - + let r = hue_to_rgb(p, q, hsl.x + 1.0 / 3.0); let g = hue_to_rgb(p, q, hsl.x); let b = hue_to_rgb(p, q, hsl.x - 1.0 / 3.0); - + return vec3f(r, g, b); } @@ -145,14 +145,14 @@ fn clip_color(c_in: vec3f) -> vec3f { let l = luminosity(c); let n = min(min(c.r, c.g), c.b); let x = max(max(c.r, c.g), c.b); - + if (n < 0.0) { c = l + (((c - l) * l) / (l - n)); } if (x > 1.0) { c = l + (((c - l) * (1.0 - l)) / (x - l)); } - + return c; } @@ -166,13 +166,13 @@ fn set_sat(c: vec3f, s: f32) -> vec3f { var result = c; let min_val = min(min(c.r, c.g), c.b); let max_val = max(max(c.r, c.g), c.b); - + if (max_val > min_val) { result = (c - min_val) * s / (max_val - min_val); } else { result = vec3f(0.0, 0.0, 0.0); } - + return result; } @@ -181,10 +181,10 @@ fn apply_composite(src: vec4f, dst: vec4f, op: u32) -> vec4f { // Premultiply alpha for proper blending let src_rgb = src.rgb * src.a; let dst_rgb = dst.rgb * dst.a; - + var result_rgb: vec3f; var result_a: f32; - + switch op { case 0u: { // source-over result_rgb = src_rgb + dst_rgb * (1.0 - src.a); @@ -336,24 +336,24 @@ fn apply_composite(src: vec4f, dst: vec4f, op: u32) -> vec4f { result_a = src.a + dst.a * (1.0 - src.a); } } - + // Clamp and unpremultiply alpha result_a = clamp(result_a, 0.0, 1.0); if (result_a > 0.0) { result_rgb = clamp(result_rgb / result_a, vec3f(0.0), vec3f(1.0)); } - + return vec4f(result_rgb, result_a); } @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4f { var src_color: vec4f; - + // Handle texture sampling for images and patterns if (uniforms.has_texture == 1u) { var tex_coord = in.tex_coord; - + // Apply pattern repetition modes if (uniforms.fill_style == 4u) { // Pattern fill style if (uniforms.pattern_repetition == 1u) { // repeat-x @@ -368,9 +368,10 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4f { } } } - + let tex_color = textureSample(texture, texture_sampler, tex_coord); - src_color = tex_color * uniforms.global_alpha; + src_color = tex_color; + src_color.w *= uniforms.global_alpha; } else if (uniforms.is_stroke == 1u) { src_color = uniforms.stroke_color; src_color.w *= uniforms.global_alpha; @@ -411,34 +412,34 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4f { var start_vec = vec2f(cos(start_angle), sin(start_angle)); // atan2 is stable for all inputs ratio = atan2( - dot(pos_vec, start_vec), + dot(pos_vec, start_vec), determinant(mat2x2f(pos_vec, start_vec)) ) / radians(360.0) + 0.5; } for(var i = 0u; i < arrayLength(&gradient) - 1; i++) { color = mix( - color, - gradient[i + 1].color, + color, + gradient[i + 1].color, smoothstep(gradient[i].offset, gradient[i + 1].offset, ratio) ); } src_color = color; src_color.w *= uniforms.global_alpha; } - + // Apply shadow effects // Note: Full shadow blur would require a multi-pass Gaussian blur implementation // For now, we apply shadow offset and color modulation // The shadow blur effect would be best implemented as a separate render pass var final_color = src_color; - + if (uniforms.shadow_blur > 0.0 || uniforms.shadow_offset.x != 0.0 || uniforms.shadow_offset.y != 0.0) { // Shadow is applied by the renderer in a separate pass // Here we just render the shape normally // The shadow will be rendered first in the renderer final_color = src_color; } - + // Note: For proper compositing with existing canvas content, we would need // to load the destination color from the render target. This requires // framebuffer fetch or a separate texture read. For now, we assume a @@ -447,13 +448,11 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4f { // 1. Use a texture attachment to store the current canvas state // 2. Sample from it here: let dst_color = textureLoad(canvas_texture, ...); // 3. Apply compositing: return apply_composite(src_color, dst_color, uniforms.composite_operation); - + let dst_color = vec4f(0.0, 0.0, 0.0, 0.0); // Placeholder for destination - - // Apply composite operation if (uniforms.composite_operation == 0u) { - // Fast path for source-over (most common case) - return final_color; + // Fast path for source-over (most common case). + return vec4f(final_color.rgb * final_color.w, final_color.w); } else { return apply_composite(final_color, dst_color, uniforms.composite_operation); } diff --git a/crates/runtime/src/ext/canvas/text.rs b/crates/runtime/src/ext/canvas/text.rs index 3bfc60ce..93c3d8ef 100644 --- a/crates/runtime/src/ext/canvas/text.rs +++ b/crates/runtime/src/ext/canvas/text.rs @@ -172,7 +172,7 @@ impl TextRenderer { for gy in 0..placement.height as i32 { for gx in 0..placement.width as i32 { let tx = x + gx + placement.left; - let ty = target_height as i32 - (y + gy - placement.top) - 1; + let ty = y + gy - placement.top; if tx < 0 || ty < 0 || tx >= target_width as i32 || ty >= target_height as i32 { continue; @@ -189,17 +189,15 @@ impl TextRenderer { } let alpha = image.data[glyph_idx]; - if alpha == 0 { continue; } let final_alpha = ((alpha as u16 * color[3] as u16) / 255) as u8; - let alpha_f = final_alpha as f32 / 255.0; - target[target_idx] = (color[0] as f32 * alpha_f) as u8; - target[target_idx + 1] = (color[1] as f32 * alpha_f) as u8; - target[target_idx + 2] = (color[2] as f32 * alpha_f) as u8; + target[target_idx] = color[0]; + target[target_idx + 1] = color[1]; + target[target_idx + 2] = color[2]; target[target_idx + 3] = final_alpha; } } diff --git a/crates/runtime/src/ext/command.rs b/crates/runtime/src/ext/command.rs index 9bda36a4..6ec85a84 100644 --- a/crates/runtime/src/ext/command.rs +++ b/crates/runtime/src/ext/command.rs @@ -273,7 +273,7 @@ impl CommandExt { cmd.stderr(parse_stdio(&opts.stderr)); #[cfg(unix)] { - use std::os::unix::process::CommandExt as UnixCommandExt; + use std::os::unix::process::CommandExt as _; if let Some(uid) = opts.uid { cmd.uid(uid); } @@ -308,7 +308,6 @@ impl CommandExt { cmd.stderr(parse_stdio(&opts.stderr)); #[cfg(unix)] { - use std::os::unix::process::CommandExt as UnixCommandExt; if let Some(uid) = opts.uid { cmd.uid(uid); } diff --git a/crates/runtime/src/ext/fs.rs b/crates/runtime/src/ext/fs.rs index a255a6eb..f927066d 100644 --- a/crates/runtime/src/ext/fs.rs +++ b/crates/runtime/src/ext/fs.rs @@ -467,11 +467,10 @@ impl FsExt { } }; - // For now, just write the string as bytes - // TODO: handle Uint8Array directly - let content = content_str.as_bytes(); + // TODO: when nova supports it, accept an actual Uint8Array and write those bytes directly + let content: Vec = content_str.chars().map(|c| c as u8).collect(); - match std::fs::write(&resolved_path, content) { + match std::fs::write(&resolved_path, &content) { Ok(_) => Ok(Value::from_string(agent, "Success".to_string(), gc.nogc()).unbind()), Err(e) => { let error = RuntimeError::fs_error(e, "write_file", path); @@ -1362,10 +1361,11 @@ impl FsExt { let macro_task_tx = host_data.macro_task_tx(); host_data.spawn_macro_task(async move { - // For now, just write the string as bytes - // TODO: In a full implementation, you'd want to handle Uint8Array directly - let content = content_string.as_bytes(); - let result = tokio::fs::write(&path_string, content).await; + // Mirror the sync path: the TS wrapper passes a binary string + // where each code unit carries one byte. Map every code point + // back to its low 8 bits before writing. + let content: Vec = content_string.chars().map(|c| c as u8).collect(); + let result = tokio::fs::write(&path_string, &content).await; match result { Ok(_) => { macro_task_tx diff --git a/crates/runtime/src/ext/web/mod.rs b/crates/runtime/src/ext/web/mod.rs index 0c5f4a8f..9ff99504 100644 --- a/crates/runtime/src/ext/web/mod.rs +++ b/crates/runtime/src/ext/web/mod.rs @@ -85,20 +85,19 @@ impl WebExt { .expect("String is not valid UTF-8") .to_string(); let gc = gc.into_nogc(); + let mut bytes = Vec::with_capacity(rust_string.chars().count()); for c in rust_string.chars() { - if c as u32 > 0xFF { + let cp = c as u32; + if cp > 0xFF { // TODO: Returning an InvalidCharacterError is the correct behavior. // ref: https://html.spec.whatwg.org/multipage/webappapis.html#atob return Err(agent.throw_exception(ExceptionType::Error, format!( "InvalidCharacterError: The string to be encoded contains characters outside of the Latin1 range. Found: '{c}'" ), gc).unbind()); } + bytes.push(cp as u8); } - Ok(Self::forgiving_base64_encode( - agent, - rust_string.as_bytes(), - gc, - )) + Ok(Self::forgiving_base64_encode(agent, &bytes, gc)) } pub fn internal_atob<'gc>( @@ -154,12 +153,8 @@ impl WebExt { ).unbind()); } }; - Ok(Value::from_string( - agent, - String::from_utf8_lossy(decoded_bytes).to_string(), - gc, - ) - .unbind()) + let decoded_string: String = decoded_bytes.iter().map(|&b| b as char).collect(); + Ok(Value::from_string(agent, decoded_string, gc).unbind()) } pub fn internal_text_encode<'gc>( diff --git a/examples/canvas_advanced.ts b/examples/canvas_advanced.ts index 0b26c5ed..6f095858 100644 --- a/examples/canvas_advanced.ts +++ b/examples/canvas_advanced.ts @@ -1,88 +1,121 @@ -// Example demonstrating advanced Canvas 2D features with GPU acceleration -// Create a canvas -const canvas = new OffscreenCanvas(800, 600); -const ctx = canvas.getContext("2d"); - -if (!ctx) { - console.error("Failed to get 2D context"); - throw new Error("Canvas context not available"); +const W = 900; +const H = 600; +const canvas = new OffscreenCanvas(W, H); +const ctx = canvas.getContext("2d")!; + +ctx.fillStyle = "#0b1020"; +ctx.fillRect(0, 0, W, H); + +const star5 = new Path2D(); + +const rO = 1; +const rI = 0.4; +for (let i = 0; i < 10; i++) { + const r = i % 2 === 0 ? rO : rI; + const a = (i * Math.PI) / 5 - Math.PI / 2; + const x = r * Math.cos(a); + const y = r * Math.sin(a); + if (i === 0) star5.moveTo(x, y); + else star5.lineTo(x, y); } +star5.closePath(); -// Clear the canvas with a background -ctx.fillStyle = "#2c3e50"; -ctx.fillRect(0, 0, 800, 600); +const rand = Math.random; -// Draw some filled rectangles -ctx.fillStyle = "#e74c3c"; -ctx.fillRect(50, 50, 100, 80); +const drawStar = ( + cx: number, + cy: number, + size: number, + rotation: number, + color: string, +) => { + const p = new Path2D(); + p.addPath( + star5, + new DOMMatrix().translate(cx, cy).rotate(rotation).scale(size, size), + ); + ctx.fillStyle = color; + ctx.fill(p); +}; -ctx.fillStyle = "#3498db"; -ctx.fillRect(200, 50, 100, 80); +const logoCx = 450; +const logoCy = 300; +const exclusion = 220; -// Draw shapes using paths -ctx.beginPath(); -ctx.fillStyle = "#2ecc71"; -ctx.moveTo(400, 50); -ctx.lineTo(450, 150); -ctx.lineTo(350, 150); -ctx.closePath(); -ctx.fill(); +for (let i = 0; i < 180; i++) { + const x = rand() * W; + const y = rand() * H; + const dx = x - logoCx; + const dy = y - logoCy; + if (dx * dx + dy * dy < exclusion * exclusion) continue; + const size = 1 + rand() * 3.5; + const brightness = 0.3 + rand() * 0.7; + const color = `rgba(255, 255, 255, ${brightness.toFixed(3)})`; + drawStar(x, y, size, rand() * 360, color); +} + +drawStar(120, 120, 9, 15, "#f8b500"); +drawStar(780, 480, 7, -12, "#f59e0b"); +drawStar(820, 100, 5, 30, "#fbbf24"); +drawStar(90, 500, 6, -25, "#f8b500"); + +ctx.save(); +const scale = 2; +ctx.translate(logoCx - 101.5 * scale, logoCy - 101.5 * scale); +ctx.scale(scale, scale); -// Draw a circle using arc +ctx.fillStyle = "#252525"; ctx.beginPath(); -ctx.fillStyle = "#f39c12"; -ctx.arc(600, 100, 40, 0, 2 * Math.PI); +ctx.arc(102, 101, 100, 0, Math.PI * 2); ctx.fill(); -// Draw stroke examples -ctx.lineWidth = 3; -ctx.strokeStyle = "#8e44ad"; +const yellowStar = new Path2D( + "M101.7 49.4448L113.433 85.5527H151.399L120.683 107.869L132.416 " + + "143.977L101.7 121.661L70.9852 143.977L82.7174 107.869L52.0021 " + + "85.5527H89.9683L101.7 49.4448Z", +); +ctx.fillStyle = "#FFB800"; +ctx.fill(yellowStar); -// Draw a stroked rectangle path -ctx.beginPath(); -ctx.rect(50, 200, 100, 80); -ctx.stroke(); +const amberStar = new Path2D( + "M71.7277 58.8951L102.049 81.7437L133.149 59.9672L120.788 95.8649" + + "L151.109 118.714L113.149 118.051L100.788 153.949L89.6883 117.641" + + "L51.7279 116.979L82.828 95.2023Z", +); +ctx.fillStyle = "#FF9900"; +ctx.fill(amberStar); -// Draw a stroked path -ctx.beginPath(); -ctx.moveTo(200, 200); -ctx.lineTo(250, 240); -ctx.lineTo(300, 200); -ctx.lineTo(300, 280); -ctx.lineTo(200, 280); -ctx.stroke(); +ctx.strokeStyle = "#FF9900"; +ctx.lineWidth = 9; +ctx.lineCap = "round"; -// Draw a stroked circle +const ringCircumference = 2 * Math.PI * 84; +const segments = 8; +const visible = ringCircumference / (segments * 2.8); +const gap = ringCircumference / segments - visible; +ctx.setLineDash([visible, gap]); ctx.beginPath(); -ctx.arc(450, 240, 40, 0, 2 * Math.PI); +ctx.arc(102, 101, 84, 0, Math.PI * 2); ctx.stroke(); +ctx.setLineDash([]); -// Draw Bezier curves -ctx.lineWidth = 2; -ctx.strokeStyle = "#e67e22"; -ctx.beginPath(); -ctx.moveTo(50, 350); -ctx.bezierCurveTo(150, 300, 250, 400, 350, 350); -ctx.stroke(); +ctx.restore(); -// Complex shape with multiple paths -ctx.fillStyle = "#9b59b6"; -ctx.beginPath(); -ctx.arc(500, 400, 30, 0, Math.PI); -ctx.moveTo(530, 400); -ctx.arc(500, 450, 30, 0, Math.PI); -ctx.fill(); +ctx.fillStyle = "#e2e8f0"; +ctx.font = "bold 24px sans-serif"; +ctx.textAlign = "center"; +ctx.fillText("Andromeda", logoCx, H - 60); -// Create a more complex path with arcs and lines -ctx.strokeStyle = "#34495e"; -ctx.lineWidth = 4; -ctx.beginPath(); -ctx.moveTo(600, 350); -ctx.arc(650, 375, 25, 0, Math.PI); -ctx.lineTo(700, 400); -ctx.arc(700, 450, 25, Math.PI, 0); -ctx.lineTo(625, 475); -ctx.closePath(); -ctx.stroke(); +ctx.fillStyle = "#94a3b8"; +ctx.font = "12px sans-serif"; +ctx.fillText("a javascript & typescript runtime", logoCx, H - 36); -canvas.saveAsPng("advanced_canvas.demo.png"); +const url = canvas.toDataURL("image/png"); +const b64 = url.slice(url.indexOf(",") + 1); +const binary = atob(b64); +const bytes = new Uint8Array(binary.length); +for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); +} +await Andromeda.writeFile("canvas_advanced.demo.png", bytes); +console.log(`Wrote ${bytes.length} bytes to canvas_advanced.demo.png`); diff --git a/namespace/mod.ts b/namespace/mod.ts index c56fe4e7..42adc4f0 100644 --- a/namespace/mod.ts +++ b/namespace/mod.ts @@ -72,7 +72,11 @@ class ChildProcess { return this.#getStatus(); } - async #getStatus(): Promise<{ success: boolean; code: number; signal: string }> { + async #getStatus(): Promise<{ + success: boolean; + code: number; + signal: string; + }> { const result = await __andromeda__.internal_command_wait(this.#rid); return JSON.parse(result); } @@ -118,18 +122,21 @@ class Command { #program: string; #optsJson: string; - constructor(program: string, options?: { - args?: string[]; - cwd?: string; - env?: Record; - clearEnv?: boolean; - stdin?: string; - stdout?: string; - stderr?: string; - uid?: number; - gid?: number; - windowsRawArguments?: boolean; - }) { + constructor( + program: string, + options?: { + args?: string[]; + cwd?: string; + env?: Record; + clearEnv?: boolean; + stdin?: string; + stdout?: string; + stderr?: string; + uid?: number; + gid?: number; + windowsRawArguments?: boolean; + }, + ) { this.#program = program; // Serialize options to JSON for the Rust backend const opts: Record = {}; @@ -143,7 +150,8 @@ class Command { if (options.stderr) opts["stderr"] = options.stderr; if (options.uid !== undefined) opts["uid"] = options.uid; if (options.gid !== undefined) opts["gid"] = options.gid; - if (options.windowsRawArguments) opts["windowsRawArguments"] = options.windowsRawArguments; + if (options.windowsRawArguments) + opts["windowsRawArguments"] = options.windowsRawArguments; } this.#optsJson = JSON.stringify(opts); } @@ -201,6 +209,19 @@ class Command { } } +// TODO: This is a workaround to convert Uint8Array to a binary string for the Rust backend. +function bytesToBinaryString(data: Uint8Array): string { + let out = ""; + const CHUNK = 0x8000; + for (let i = 0; i < data.length; i += CHUNK) { + const end = Math.min(i + CHUNK, data.length); + for (let j = i; j < end; j++) { + out += String.fromCharCode(data[j]); + } + } + return out; +} + /** * Andromeda namespace for the Andromeda runtime. */ @@ -297,7 +318,7 @@ const Andromeda = { * ``` */ writeFileSync(path: string, data: Uint8Array): void { - __andromeda__.internal_write_file(path, data); + __andromeda__.internal_write_file(path, bytesToBinaryString(data)); }, /** @@ -310,7 +331,10 @@ const Andromeda = { * ``` */ async writeFile(path: string, data: Uint8Array): Promise { - await __andromeda__.internal_write_file_async(path, data); + await __andromeda__.internal_write_file_async( + path, + bytesToBinaryString(data), + ); }, /** @@ -721,7 +745,7 @@ const Andromeda = { */ remove(key: string): void { __andromeda__.internal_delete_env(key); - }, /** + } /** * The `keys` function gets the environment variable keys. * * @example @@ -729,7 +753,7 @@ const Andromeda = { * const keys = Andromeda.env.keys(); * console.log(keys); * ``` - */ + */, keys(): string[] { return __andromeda__.internal_get_env_keys(); }, diff --git a/types/global.d.ts b/types/global.d.ts index b0bd5f8e..c945e001 100644 --- a/types/global.d.ts +++ b/types/global.d.ts @@ -780,6 +780,14 @@ declare class OffscreenCanvas { * Returns true if save was successful, false otherwise. */ saveAsPng(path: string): boolean; + /** Encode the canvas to image bytes. Supports "image/png" (default) and "image/jpeg". */ + toBuffer(type?: "image/png" | "image/jpeg", quality?: number): Uint8Array; + /** Encode the canvas as a `data:` URL. Supports "image/png" (default) and "image/jpeg". */ + toDataURL(type?: string, quality?: number): string; + /** Encode the canvas as a Blob. */ + convertToBlob( + options?: { type?: string; quality?: number }, + ): Promise; } /** @@ -790,19 +798,42 @@ declare class CanvasRenderingContext2D { fillStyle: string | CanvasGradient; /** Gets or sets the current stroke style for drawing operations. */ strokeStyle: string; - /** Gets or sets the line width for drawing operations. */ + /** Gets or sets the line width for drawing operations. Default 1. */ lineWidth: number; + /** Gets or sets the line cap style. Default "butt". */ + lineCap: "butt" | "round" | "square"; + /** Gets or sets the line join style. Default "miter". */ + lineJoin: "miter" | "round" | "bevel"; + /** Gets or sets the miter limit. Default 10. */ + miterLimit: number; + /** Gets or sets the line dash offset. Default 0. */ + lineDashOffset: number; + /** Gets or sets image smoothing. Default true. */ + imageSmoothingEnabled: boolean; + /** Gets or sets image smoothing quality. Default "low". */ + imageSmoothingQuality: "low" | "medium" | "high"; + /** Gets or sets a CSS filter string. Default "none". */ + filter: string; /** Gets or sets the global alpha value (transparency) for drawing operations. Values range from 0.0 (transparent) to 1.0 (opaque). */ globalAlpha: number; /** Gets or sets the type of compositing operation to apply when drawing new shapes. Valid values include 'source-over', 'source-in', 'source-out', 'source-atop', 'destination-over', 'destination-in', 'destination-out', 'destination-atop', 'lighter', 'copy', 'xor', 'multiply', 'screen', 'overlay', 'darken', 'lighten', 'color-dodge', 'color-burn', 'hard-light', 'soft-light', 'difference', 'exclusion', 'hue', 'saturation', 'color', and 'luminosity'. Default is 'source-over'. */ globalCompositeOperation: string; - /** Creates an arc/curve on the canvas context. */ + /** Resets the context to its default state per HTML spec. */ + reset(): void; + /** Returns false — Andromeda never loses the canvas context. */ + isContextLost(): boolean; + /** Sets the line dash pattern. */ + setLineDash(segments: number[]): void; + /** Returns the current line dash pattern. */ + getLineDash(): number[]; + /** Creates an arc on the current path. */ arc( x: number, y: number, radius: number, startAngle: number, endAngle: number, + counterclockwise?: boolean, ): void; /** Creates an arc-to command on the canvas context. */ arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): void; @@ -852,9 +883,18 @@ declare class CanvasRenderingContext2D { x: number, y: number, ): void; /** Fills the current path with the current fill style. */ - fill(): void; - /** Strokes the current path with the current stroke style. */ - stroke(): void; /** Adds a rectangle to the current path. */ + fill(pathOrRule?: Path2D | "nonzero" | "evenodd", fillRule?: "nonzero" | "evenodd"): void; + /** Strokes the current path or given Path2D with the current stroke style. */ + stroke(path?: Path2D): void; + /** Turns the current path (or given Path2D) into the clipping region. */ + clip(pathOrRule?: Path2D | "nonzero" | "evenodd", fillRule?: "nonzero" | "evenodd"): void; + /** Returns whether the given point is inside the current path. */ + isPointInPath(x: number, y: number, fillRule?: "nonzero" | "evenodd"): boolean; + isPointInPath(path: Path2D, x: number, y: number, fillRule?: "nonzero" | "evenodd"): boolean; + /** Returns whether the given point is inside the stroked path. */ + isPointInStroke(x: number, y: number): boolean; + isPointInStroke(path: Path2D, x: number, y: number): boolean; + /** Adds a rectangle to the current path. */ rect(x: number, y: number, width: number, height: number): void; /** Adds a quadratic Bézier curve to the current path. */ quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void; @@ -875,7 +915,10 @@ declare class CanvasRenderingContext2D { y: number, w: number, h: number, - radii: number | number[], + radii?: + | number + | { x: number; y: number } + | Array, ): void; /** Saves the current canvas state (styles, transformations, etc.) to a stack. */ save(): void; @@ -937,6 +980,8 @@ declare class ImageBitmap { declare class ImageData { /** Creates a new ImageData object with the specified dimensions. */ constructor(width: number, height: number); + /** Creates an ImageData wrapping an existing Uint8ClampedArray. */ + constructor(data: Uint8ClampedArray, width: number, height?: number); /** The width of the ImageData in pixels. */ readonly width: number; @@ -946,6 +991,62 @@ declare class ImageData { readonly data: Uint8ClampedArray; } +interface DOMMatrix2DInit { + a?: number; + b?: number; + c?: number; + d?: number; + e?: number; + f?: number; + m11?: number; + m12?: number; + m21?: number; + m22?: number; + m41?: number; + m42?: number; +} + +declare class DOMMatrixReadOnly { + constructor(init?: number[] | DOMMatrix2DInit | string); + readonly a: number; + readonly b: number; + readonly c: number; + readonly d: number; + readonly e: number; + readonly f: number; + readonly m11: number; + readonly m12: number; + readonly m21: number; + readonly m22: number; + readonly m41: number; + readonly m42: number; + readonly is2D: boolean; + readonly isIdentity: boolean; + multiply(other: DOMMatrix2DInit | DOMMatrixReadOnly): DOMMatrix; + translate(tx: number, ty?: number): DOMMatrix; + scale(sx: number, sy?: number): DOMMatrix; + rotate(angleDegrees: number): DOMMatrix; + inverse(): DOMMatrix; + transformPoint(point: { x: number; y: number }): { x: number; y: number }; + toFloat32Array(): Float32Array; + toString(): string; +} + +declare class DOMMatrix extends DOMMatrixReadOnly { + constructor(init?: number[] | DOMMatrix2DInit | string); + a: number; + b: number; + c: number; + d: number; + e: number; + f: number; + multiplySelf(other: DOMMatrix2DInit | DOMMatrixReadOnly): DOMMatrix; + translateSelf(tx: number, ty?: number): DOMMatrix; + scaleSelf(sx: number, sy?: number): DOMMatrix; + rotateSelf(angleDegrees: number): DOMMatrix; + invertSelf(): DOMMatrix; +} + /** * Creates an ImageBitmap from a file path or URL. * @param path The file path or URL to load.