we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement canvas 2D state management & transformation matrix (Phase 18)

Add Canvas2dState, Canvas2dContext, and AffineTransform to the dom crate,
with full state stack (save/restore) and CTM operations (translate, rotate,
scale, transform, setTransform, resetTransform, getTransform). Wire up JS
bindings on the CanvasRenderingContext2D wrapper object. 25 Rust-level and
10 JS integration tests covering all transform operations, nested
save/restore, edge cases, and matrix inversion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+1027 -1
+620
crates/dom/src/canvas.rs
··· 1 + //! Canvas 2D rendering context state management. 2 + //! 3 + //! Implements the state stack (`save`/`restore`) and the current transformation 4 + //! matrix (CTM) with all affine transform operations required by the Canvas 2D 5 + //! specification. 6 + 7 + use std::collections::HashMap; 8 + 9 + use crate::NodeId; 10 + 11 + // ── Affine Transform ────────────────────────────────────────────── 12 + 13 + /// A 2D affine transform represented as a 3×2 matrix: 14 + /// ```text 15 + /// | a c e | 16 + /// | b d f | 17 + /// | 0 0 1 | 18 + /// ``` 19 + /// Uses `f64` throughout to match JavaScript's number precision. 20 + #[derive(Debug, Clone, Copy, PartialEq)] 21 + pub struct AffineTransform { 22 + pub a: f64, 23 + pub b: f64, 24 + pub c: f64, 25 + pub d: f64, 26 + pub e: f64, 27 + pub f: f64, 28 + } 29 + 30 + impl AffineTransform { 31 + /// The identity transform. 32 + pub fn identity() -> Self { 33 + AffineTransform { 34 + a: 1.0, 35 + b: 0.0, 36 + c: 0.0, 37 + d: 1.0, 38 + e: 0.0, 39 + f: 0.0, 40 + } 41 + } 42 + 43 + /// Construct from six components. 44 + pub fn new(a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) -> Self { 45 + AffineTransform { a, b, c, d, e, f } 46 + } 47 + 48 + /// Post-multiply by a translation: `self = self * translate(tx, ty)`. 49 + pub fn translate(&mut self, tx: f64, ty: f64) { 50 + self.e += self.a * tx + self.c * ty; 51 + self.f += self.b * tx + self.d * ty; 52 + } 53 + 54 + /// Post-multiply by a rotation (angle in radians): `self = self * rotate(angle)`. 55 + pub fn rotate(&mut self, angle: f64) { 56 + let cos = angle.cos(); 57 + let sin = angle.sin(); 58 + let a = self.a * cos + self.c * sin; 59 + let b = self.b * cos + self.d * sin; 60 + let c = self.a * -sin + self.c * cos; 61 + let d = self.b * -sin + self.d * cos; 62 + self.a = a; 63 + self.b = b; 64 + self.c = c; 65 + self.d = d; 66 + } 67 + 68 + /// Post-multiply by a scale: `self = self * scale(sx, sy)`. 69 + pub fn scale(&mut self, sx: f64, sy: f64) { 70 + self.a *= sx; 71 + self.b *= sx; 72 + self.c *= sy; 73 + self.d *= sy; 74 + } 75 + 76 + /// Post-multiply by an arbitrary affine transform: `self = self * other`. 77 + pub fn multiply(&mut self, other: &AffineTransform) { 78 + let a = self.a * other.a + self.c * other.b; 79 + let b = self.b * other.a + self.d * other.b; 80 + let c = self.a * other.c + self.c * other.d; 81 + let d = self.b * other.c + self.d * other.d; 82 + let e = self.a * other.e + self.c * other.f + self.e; 83 + let f = self.b * other.e + self.d * other.f + self.f; 84 + self.a = a; 85 + self.b = b; 86 + self.c = c; 87 + self.d = d; 88 + self.e = e; 89 + self.f = f; 90 + } 91 + 92 + /// Apply this transform to a point, returning the transformed coordinates. 93 + pub fn apply(&self, x: f64, y: f64) -> (f64, f64) { 94 + ( 95 + self.a * x + self.c * y + self.e, 96 + self.b * x + self.d * y + self.f, 97 + ) 98 + } 99 + 100 + /// Compute the inverse of this transform, if it exists (non-singular). 101 + pub fn inverse(&self) -> Option<AffineTransform> { 102 + let det = self.a * self.d - self.b * self.c; 103 + if det.abs() < 1e-15 { 104 + return None; 105 + } 106 + let inv_det = 1.0 / det; 107 + Some(AffineTransform { 108 + a: self.d * inv_det, 109 + b: -self.b * inv_det, 110 + c: -self.c * inv_det, 111 + d: self.a * inv_det, 112 + e: (self.c * self.f - self.d * self.e) * inv_det, 113 + f: (self.b * self.e - self.a * self.f) * inv_det, 114 + }) 115 + } 116 + } 117 + 118 + // ── Canvas 2D Drawing State ─────────────────────────────────────── 119 + 120 + /// The full drawing state of a `CanvasRenderingContext2D`. 121 + /// 122 + /// Every property here is saved/restored by `save()`/`restore()`. 123 + /// Properties not yet exposed via JS getters/setters still participate 124 + /// in the state stack so future issues can wire them up without changing 125 + /// the save/restore logic. 126 + #[derive(Debug, Clone)] 127 + pub struct Canvas2dState { 128 + /// Current transformation matrix. 129 + pub transform: AffineTransform, 130 + 131 + // ── Styles (defaults per Canvas 2D spec) ───────────── 132 + /// Fill color as RGBA (0–255). Default: opaque black. 133 + pub fill_style: [u8; 4], 134 + /// Stroke color as RGBA (0–255). Default: opaque black. 135 + pub stroke_style: [u8; 4], 136 + 137 + // ── Line styles ────────────────────────────────────── 138 + pub line_width: f64, 139 + /// 0 = butt, 1 = round, 2 = square 140 + pub line_cap: u8, 141 + /// 0 = miter, 1 = round, 2 = bevel 142 + pub line_join: u8, 143 + pub miter_limit: f64, 144 + pub line_dash: Vec<f64>, 145 + pub line_dash_offset: f64, 146 + 147 + // ── Text ───────────────────────────────────────────── 148 + pub font: String, 149 + /// 0 = start, 1 = end, 2 = left, 3 = right, 4 = center 150 + pub text_align: u8, 151 + /// 0 = alphabetic, 1 = top, 2 = hanging, 3 = middle, 4 = ideographic, 5 = bottom 152 + pub text_baseline: u8, 153 + /// 0 = inherit, 1 = ltr, 2 = rtl 154 + pub direction: u8, 155 + 156 + // ── Compositing ────────────────────────────────────── 157 + pub global_alpha: f64, 158 + /// Composite operation name (default: "source-over"). 159 + pub global_composite_operation: String, 160 + 161 + // ── Shadows ────────────────────────────────────────── 162 + pub shadow_color: [u8; 4], 163 + pub shadow_blur: f64, 164 + pub shadow_offset_x: f64, 165 + pub shadow_offset_y: f64, 166 + 167 + // ── Image smoothing ────────────────────────────────── 168 + pub image_smoothing_enabled: bool, 169 + /// 0 = low, 1 = medium, 2 = high 170 + pub image_smoothing_quality: u8, 171 + } 172 + 173 + impl Default for Canvas2dState { 174 + fn default() -> Self { 175 + Canvas2dState { 176 + transform: AffineTransform::identity(), 177 + fill_style: [0, 0, 0, 255], 178 + stroke_style: [0, 0, 0, 255], 179 + line_width: 1.0, 180 + line_cap: 0, 181 + line_join: 0, 182 + miter_limit: 10.0, 183 + line_dash: Vec::new(), 184 + line_dash_offset: 0.0, 185 + font: "10px sans-serif".to_string(), 186 + text_align: 0, 187 + text_baseline: 0, 188 + direction: 0, 189 + global_alpha: 1.0, 190 + global_composite_operation: "source-over".to_string(), 191 + shadow_color: [0, 0, 0, 0], 192 + shadow_blur: 0.0, 193 + shadow_offset_x: 0.0, 194 + shadow_offset_y: 0.0, 195 + image_smoothing_enabled: true, 196 + image_smoothing_quality: 0, 197 + } 198 + } 199 + } 200 + 201 + // ── Canvas 2D Context ───────────────────────────────────────────── 202 + 203 + /// Per-canvas 2D rendering context, holding the current state and state stack. 204 + #[derive(Debug, Clone, Default)] 205 + pub struct Canvas2dContext { 206 + /// The current drawing state. 207 + pub state: Canvas2dState, 208 + /// Stack of saved states (pushed by `save()`, popped by `restore()`). 209 + stack: Vec<Canvas2dState>, 210 + } 211 + 212 + impl Canvas2dContext { 213 + /// Create a new context with default state and an empty stack. 214 + pub fn new() -> Self { 215 + Canvas2dContext { 216 + state: Canvas2dState::default(), 217 + stack: Vec::new(), 218 + } 219 + } 220 + 221 + /// Push the current state onto the stack. 222 + pub fn save(&mut self) { 223 + self.stack.push(self.state.clone()); 224 + } 225 + 226 + /// Pop the most recently saved state. If the stack is empty, this is a no-op. 227 + pub fn restore(&mut self) { 228 + if let Some(saved) = self.stack.pop() { 229 + self.state = saved; 230 + } 231 + } 232 + 233 + /// Returns the depth of the state stack (for testing). 234 + pub fn stack_depth(&self) -> usize { 235 + self.stack.len() 236 + } 237 + 238 + // ── Transform helpers ───────────────────────────────── 239 + 240 + /// Post-multiply the CTM by a translation. 241 + pub fn translate(&mut self, tx: f64, ty: f64) { 242 + self.state.transform.translate(tx, ty); 243 + } 244 + 245 + /// Post-multiply the CTM by a rotation (angle in radians). 246 + pub fn rotate(&mut self, angle: f64) { 247 + self.state.transform.rotate(angle); 248 + } 249 + 250 + /// Post-multiply the CTM by a scale. 251 + pub fn scale(&mut self, sx: f64, sy: f64) { 252 + self.state.transform.scale(sx, sy); 253 + } 254 + 255 + /// Post-multiply the CTM by an arbitrary affine transform. 256 + pub fn transform(&mut self, a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) { 257 + let other = AffineTransform::new(a, b, c, d, e, f); 258 + self.state.transform.multiply(&other); 259 + } 260 + 261 + /// Reset the CTM to the given transform. 262 + pub fn set_transform(&mut self, a: f64, b: f64, c: f64, d: f64, e: f64, f: f64) { 263 + self.state.transform = AffineTransform::new(a, b, c, d, e, f); 264 + } 265 + 266 + /// Reset the CTM to identity. 267 + pub fn reset_transform(&mut self) { 268 + self.state.transform = AffineTransform::identity(); 269 + } 270 + 271 + /// Return a copy of the current transform. 272 + pub fn get_transform(&self) -> AffineTransform { 273 + self.state.transform 274 + } 275 + } 276 + 277 + // ── Canvas context storage on Document ──────────────────────────── 278 + 279 + /// Storage for all canvas 2D contexts, keyed by the `<canvas>` NodeId. 280 + #[derive(Debug, Default)] 281 + pub struct CanvasContextStore { 282 + contexts: HashMap<NodeId, Canvas2dContext>, 283 + } 284 + 285 + impl CanvasContextStore { 286 + pub fn new() -> Self { 287 + CanvasContextStore { 288 + contexts: HashMap::new(), 289 + } 290 + } 291 + 292 + /// Get or create the 2D context for a canvas node. 293 + pub fn get_or_create(&mut self, node: NodeId) -> &mut Canvas2dContext { 294 + self.contexts.entry(node).or_default() 295 + } 296 + 297 + /// Get the 2D context for a canvas node, if it exists. 298 + pub fn get(&self, node: NodeId) -> Option<&Canvas2dContext> { 299 + self.contexts.get(&node) 300 + } 301 + 302 + /// Get a mutable reference to the 2D context for a canvas node. 303 + pub fn get_mut(&mut self, node: NodeId) -> Option<&mut Canvas2dContext> { 304 + self.contexts.get_mut(&node) 305 + } 306 + 307 + /// Remove the context when a canvas is destroyed or resized (per spec, 308 + /// resizing resets the context state). 309 + pub fn remove(&mut self, node: NodeId) { 310 + self.contexts.remove(&node); 311 + } 312 + } 313 + 314 + // ── Tests ───────────────────────────────────────────────────────── 315 + 316 + #[cfg(test)] 317 + mod tests { 318 + use super::*; 319 + use std::f64::consts::PI; 320 + 321 + // ── AffineTransform tests ───────────────────────────── 322 + 323 + #[test] 324 + fn identity_preserves_point() { 325 + let t = AffineTransform::identity(); 326 + let (x, y) = t.apply(42.0, 99.0); 327 + assert!((x - 42.0).abs() < 1e-10); 328 + assert!((y - 99.0).abs() < 1e-10); 329 + } 330 + 331 + #[test] 332 + fn translate_shifts_point() { 333 + let mut t = AffineTransform::identity(); 334 + t.translate(10.0, 20.0); 335 + let (x, y) = t.apply(5.0, 3.0); 336 + assert!((x - 15.0).abs() < 1e-10); 337 + assert!((y - 23.0).abs() < 1e-10); 338 + } 339 + 340 + #[test] 341 + fn scale_multiplies() { 342 + let mut t = AffineTransform::identity(); 343 + t.scale(2.0, 3.0); 344 + let (x, y) = t.apply(5.0, 10.0); 345 + assert!((x - 10.0).abs() < 1e-10); 346 + assert!((y - 30.0).abs() < 1e-10); 347 + } 348 + 349 + #[test] 350 + fn rotate_90_degrees() { 351 + let mut t = AffineTransform::identity(); 352 + t.rotate(PI / 2.0); 353 + let (x, y) = t.apply(1.0, 0.0); 354 + assert!(x.abs() < 1e-10); 355 + assert!((y - 1.0).abs() < 1e-10); 356 + } 357 + 358 + #[test] 359 + fn rotate_45_degrees() { 360 + let mut t = AffineTransform::identity(); 361 + t.rotate(PI / 4.0); 362 + let (x, y) = t.apply(1.0, 0.0); 363 + let expected = (2.0_f64).sqrt() / 2.0; 364 + assert!((x - expected).abs() < 1e-10); 365 + assert!((y - expected).abs() < 1e-10); 366 + } 367 + 368 + #[test] 369 + fn translate_then_scale() { 370 + // translate(100, 50) then scale(2, 2) 371 + // In canvas, this means: first scale the coord system, then translate 372 + // But since we post-multiply, translate first then scale: 373 + // Drawing at (0,0) → translate → (100,50) → but scale changes the axes 374 + // Actually: post-multiply means CTM = CTM * new_transform 375 + // So: CTM = I * T(100,50) * S(2,2) 376 + // Applying to (5, 5): S(2,2) * (5,5) = (10,10), then T(100,50) * (10,10) = (110, 60) 377 + // Wait, matrix multiplication: (I * T * S) * p = T * (S * p) = T(10,10) = (110,60) 378 + let mut t = AffineTransform::identity(); 379 + t.translate(100.0, 50.0); 380 + t.scale(2.0, 2.0); 381 + let (x, y) = t.apply(5.0, 5.0); 382 + assert!((x - 110.0).abs() < 1e-10); 383 + assert!((y - 60.0).abs() < 1e-10); 384 + } 385 + 386 + #[test] 387 + fn scale_then_translate() { 388 + // CTM = I * S(2,2) * T(100,50) 389 + // Applying to (0,0): T(100,50) * (0,0) = (100,50), then S(2,2) * (100,50) = (200,100) 390 + let mut t = AffineTransform::identity(); 391 + t.scale(2.0, 2.0); 392 + t.translate(100.0, 50.0); 393 + let (x, y) = t.apply(0.0, 0.0); 394 + assert!((x - 200.0).abs() < 1e-10); 395 + assert!((y - 100.0).abs() < 1e-10); 396 + } 397 + 398 + #[test] 399 + fn multiply_arbitrary() { 400 + let mut t = AffineTransform::identity(); 401 + // Apply a shear via transform() 402 + let shear = AffineTransform::new(1.0, 0.0, 0.5, 1.0, 0.0, 0.0); 403 + t.multiply(&shear); 404 + let (x, y) = t.apply(10.0, 10.0); 405 + // x = 1*10 + 0.5*10 = 15, y = 0*10 + 1*10 = 10 406 + assert!((x - 15.0).abs() < 1e-10); 407 + assert!((y - 10.0).abs() < 1e-10); 408 + } 409 + 410 + #[test] 411 + fn set_transform_replaces() { 412 + let mut t = AffineTransform::identity(); 413 + t.translate(100.0, 200.0); 414 + // Now replace with a scale-only transform 415 + t = AffineTransform::new(3.0, 0.0, 0.0, 3.0, 0.0, 0.0); 416 + let (x, y) = t.apply(10.0, 10.0); 417 + assert!((x - 30.0).abs() < 1e-10); 418 + assert!((y - 30.0).abs() < 1e-10); 419 + } 420 + 421 + #[test] 422 + fn inverse_of_identity() { 423 + let t = AffineTransform::identity(); 424 + let inv = t.inverse().unwrap(); 425 + assert!((inv.a - 1.0).abs() < 1e-10); 426 + assert!((inv.d - 1.0).abs() < 1e-10); 427 + assert!(inv.e.abs() < 1e-10); 428 + assert!(inv.f.abs() < 1e-10); 429 + } 430 + 431 + #[test] 432 + fn inverse_of_translation() { 433 + let t = AffineTransform::new(1.0, 0.0, 0.0, 1.0, 50.0, 100.0); 434 + let inv = t.inverse().unwrap(); 435 + let (x, y) = inv.apply(50.0, 100.0); 436 + assert!(x.abs() < 1e-10); 437 + assert!(y.abs() < 1e-10); 438 + } 439 + 440 + #[test] 441 + fn inverse_of_scale() { 442 + let t = AffineTransform::new(2.0, 0.0, 0.0, 4.0, 0.0, 0.0); 443 + let inv = t.inverse().unwrap(); 444 + let (x, y) = inv.apply(10.0, 20.0); 445 + assert!((x - 5.0).abs() < 1e-10); 446 + assert!((y - 5.0).abs() < 1e-10); 447 + } 448 + 449 + #[test] 450 + fn inverse_of_singular_is_none() { 451 + let t = AffineTransform::new(0.0, 0.0, 0.0, 0.0, 0.0, 0.0); 452 + assert!(t.inverse().is_none()); 453 + } 454 + 455 + #[test] 456 + fn round_trip_transform_inverse() { 457 + let mut t = AffineTransform::identity(); 458 + t.translate(30.0, 40.0); 459 + t.rotate(PI / 6.0); 460 + t.scale(2.0, 0.5); 461 + let inv = t.inverse().unwrap(); 462 + // Apply t then inv should give back the original point 463 + let (tx, ty) = t.apply(7.0, 13.0); 464 + let (x, y) = inv.apply(tx, ty); 465 + assert!((x - 7.0).abs() < 1e-9); 466 + assert!((y - 13.0).abs() < 1e-9); 467 + } 468 + 469 + // ── Canvas2dContext tests ───────────────────────────── 470 + 471 + #[test] 472 + fn save_restore_round_trips_transform() { 473 + let mut ctx = Canvas2dContext::new(); 474 + ctx.translate(100.0, 50.0); 475 + ctx.save(); 476 + ctx.translate(10.0, 10.0); 477 + // Current CTM should be translate(110, 60) 478 + let (x, y) = ctx.state.transform.apply(0.0, 0.0); 479 + assert!((x - 110.0).abs() < 1e-10); 480 + assert!((y - 60.0).abs() < 1e-10); 481 + 482 + ctx.restore(); 483 + // Should be back to translate(100, 50) 484 + let (x, y) = ctx.state.transform.apply(0.0, 0.0); 485 + assert!((x - 100.0).abs() < 1e-10); 486 + assert!((y - 50.0).abs() < 1e-10); 487 + } 488 + 489 + #[test] 490 + fn save_restore_round_trips_styles() { 491 + let mut ctx = Canvas2dContext::new(); 492 + ctx.state.line_width = 5.0; 493 + ctx.state.global_alpha = 0.5; 494 + ctx.save(); 495 + ctx.state.line_width = 10.0; 496 + ctx.state.global_alpha = 0.1; 497 + assert_eq!(ctx.state.line_width, 10.0); 498 + assert_eq!(ctx.state.global_alpha, 0.1); 499 + 500 + ctx.restore(); 501 + assert_eq!(ctx.state.line_width, 5.0); 502 + assert_eq!(ctx.state.global_alpha, 0.5); 503 + } 504 + 505 + #[test] 506 + fn nested_save_restore() { 507 + let mut ctx = Canvas2dContext::new(); 508 + ctx.translate(10.0, 0.0); 509 + ctx.save(); // depth 1 510 + ctx.translate(20.0, 0.0); 511 + ctx.save(); // depth 2 512 + ctx.translate(30.0, 0.0); 513 + 514 + // Current: translate(60, 0) 515 + let (x, _) = ctx.state.transform.apply(0.0, 0.0); 516 + assert!((x - 60.0).abs() < 1e-10); 517 + 518 + ctx.restore(); // back to depth 1: translate(30, 0) 519 + let (x, _) = ctx.state.transform.apply(0.0, 0.0); 520 + assert!((x - 30.0).abs() < 1e-10); 521 + 522 + ctx.restore(); // back to depth 0: translate(10, 0) 523 + let (x, _) = ctx.state.transform.apply(0.0, 0.0); 524 + assert!((x - 10.0).abs() < 1e-10); 525 + } 526 + 527 + #[test] 528 + fn restore_on_empty_stack_is_noop() { 529 + let mut ctx = Canvas2dContext::new(); 530 + ctx.translate(100.0, 200.0); 531 + ctx.restore(); // should not panic or change anything 532 + let (x, y) = ctx.state.transform.apply(0.0, 0.0); 533 + assert!((x - 100.0).abs() < 1e-10); 534 + assert!((y - 200.0).abs() < 1e-10); 535 + } 536 + 537 + #[test] 538 + fn set_transform_resets_ctm() { 539 + let mut ctx = Canvas2dContext::new(); 540 + ctx.translate(100.0, 200.0); 541 + ctx.set_transform(1.0, 0.0, 0.0, 1.0, 0.0, 0.0); 542 + let t = ctx.get_transform(); 543 + assert!((t.a - 1.0).abs() < 1e-10); 544 + assert!(t.e.abs() < 1e-10); 545 + assert!(t.f.abs() < 1e-10); 546 + } 547 + 548 + #[test] 549 + fn reset_transform_to_identity() { 550 + let mut ctx = Canvas2dContext::new(); 551 + ctx.scale(3.0, 3.0); 552 + ctx.translate(50.0, 50.0); 553 + ctx.reset_transform(); 554 + let t = ctx.get_transform(); 555 + assert!((t.a - 1.0).abs() < 1e-10); 556 + assert!((t.d - 1.0).abs() < 1e-10); 557 + assert!(t.b.abs() < 1e-10); 558 + assert!(t.c.abs() < 1e-10); 559 + assert!(t.e.abs() < 1e-10); 560 + assert!(t.f.abs() < 1e-10); 561 + } 562 + 563 + #[test] 564 + fn context_transform_method() { 565 + let mut ctx = Canvas2dContext::new(); 566 + // Apply a shear: a=1, b=0, c=0.5, d=1, e=0, f=0 567 + ctx.transform(1.0, 0.0, 0.5, 1.0, 0.0, 0.0); 568 + let (x, y) = ctx.state.transform.apply(10.0, 10.0); 569 + assert!((x - 15.0).abs() < 1e-10); 570 + assert!((y - 10.0).abs() < 1e-10); 571 + } 572 + 573 + #[test] 574 + fn get_transform_returns_copy() { 575 + let mut ctx = Canvas2dContext::new(); 576 + ctx.translate(42.0, 99.0); 577 + let t = ctx.get_transform(); 578 + assert!((t.e - 42.0).abs() < 1e-10); 579 + assert!((t.f - 99.0).abs() < 1e-10); 580 + // Modifying ctx should not affect the returned copy 581 + ctx.reset_transform(); 582 + assert!((t.e - 42.0).abs() < 1e-10); 583 + } 584 + 585 + // ── CanvasContextStore tests ────────────────────────── 586 + 587 + #[test] 588 + fn store_creates_default_context() { 589 + let mut store = CanvasContextStore::new(); 590 + let node = NodeId::from_index(0); 591 + let ctx = store.get_or_create(node); 592 + let t = ctx.get_transform(); 593 + assert!((t.a - 1.0).abs() < 1e-10); 594 + assert!(t.e.abs() < 1e-10); 595 + } 596 + 597 + #[test] 598 + fn store_preserves_state() { 599 + let mut store = CanvasContextStore::new(); 600 + let node = NodeId::from_index(0); 601 + store.get_or_create(node).translate(50.0, 50.0); 602 + let ctx = store.get_or_create(node); 603 + let (x, y) = ctx.state.transform.apply(0.0, 0.0); 604 + assert!((x - 50.0).abs() < 1e-10); 605 + assert!((y - 50.0).abs() < 1e-10); 606 + } 607 + 608 + #[test] 609 + fn store_remove_resets() { 610 + let mut store = CanvasContextStore::new(); 611 + let node = NodeId::from_index(0); 612 + store.get_or_create(node).translate(50.0, 50.0); 613 + store.remove(node); 614 + assert!(store.get(node).is_none()); 615 + // Re-creating should give fresh default state 616 + let ctx = store.get_or_create(node); 617 + let t = ctx.get_transform(); 618 + assert!(t.e.abs() < 1e-10); 619 + } 620 + }
+7
crates/dom/src/lib.rs
··· 6 6 //! Tag names and attribute names are interned via `Atom` for memory efficiency: 7 7 //! thousands of `<div>` elements share one string allocation instead of one each. 8 8 9 + pub mod canvas; 9 10 pub mod input_state; 10 11 pub mod validation; 11 12 ··· 14 15 15 16 use we_memory::intern::Atom; 16 17 18 + pub use canvas::{AffineTransform, Canvas2dContext, Canvas2dState, CanvasContextStore}; 17 19 pub use input_state::{InputState, InputStateMap}; 18 20 pub use validation::{CustomValidityMap, ValidityState}; 19 21 ··· 115 117 canvas_buffers: HashMap<NodeId, Vec<u8>>, 116 118 /// Canvas element dimensions (width, height) in CSS pixels. 117 119 canvas_sizes: HashMap<NodeId, (u32, u32)>, 120 + /// Canvas 2D rendering context state (CTM, styles, state stack) per canvas node. 121 + pub canvas_contexts: CanvasContextStore, 118 122 } 119 123 120 124 impl fmt::Debug for Document { ··· 150 154 custom_validity: CustomValidityMap::new(), 151 155 canvas_buffers: HashMap::new(), 152 156 canvas_sizes: HashMap::new(), 157 + canvas_contexts: CanvasContextStore::new(), 153 158 } 154 159 } 155 160 ··· 1018 1023 1019 1024 /// Resize a canvas backing buffer, clearing its contents per spec. 1020 1025 /// If the canvas has no buffer yet, one is created. 1026 + /// Per spec, resizing also resets the 2D context state. 1021 1027 pub fn resize_canvas(&mut self, node: NodeId, width: u32, height: u32) { 1022 1028 self.init_canvas(node, width, height); 1029 + self.canvas_contexts.remove(node); 1023 1030 } 1024 1031 1025 1032 /// Returns the canvas dimensions (width, height) for a node, if it has a canvas buffer.
+400 -1
crates/js/src/dom_bridge.rs
··· 1201 1201 /// Internal property key for storing the canvas NodeId on a context2d wrapper. 1202 1202 const CANVAS_NODE_ID_KEY: &str = "__canvas_node_id__"; 1203 1203 1204 + /// Extract the canvas `NodeId` from a CanvasRenderingContext2D wrapper's `this`. 1205 + fn get_canvas_node_id(gc: &Gc<HeapObject>, shapes: &ShapeTable, this: &Value) -> Option<NodeId> { 1206 + match this { 1207 + Value::Object(r) => match gc.get(*r) { 1208 + Some(HeapObject::Object(data)) => match data.get_property(CANVAS_NODE_ID_KEY, shapes) { 1209 + Some(Property { 1210 + value: Value::Number(n), 1211 + .. 1212 + }) => Some(NodeId::from_index(n as usize)), 1213 + _ => None, 1214 + }, 1215 + _ => None, 1216 + }, 1217 + _ => None, 1218 + } 1219 + } 1220 + 1204 1221 /// `canvas.getContext(contextType)` — returns a CanvasRenderingContext2D for 1205 1222 /// "2d", or null for unsupported types. 1206 1223 fn canvas_get_context(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { ··· 1223 1240 .as_ref() 1224 1241 .ok_or_else(|| RuntimeError::type_error("no DOM bridge"))?; 1225 1242 1226 - // Initialize the canvas backing buffer if it doesn't exist yet. 1243 + // Initialize the canvas backing buffer and 2D context if needed. 1227 1244 { 1228 1245 let mut doc = bridge.document.borrow_mut(); 1229 1246 if !doc.has_canvas(canvas_node_id) { ··· 1237 1254 .unwrap_or(150); 1238 1255 doc.init_canvas(canvas_node_id, w, h); 1239 1256 } 1257 + // Ensure a Canvas2dContext exists for this node. 1258 + doc.canvas_contexts.get_or_create(canvas_node_id); 1240 1259 } 1241 1260 1242 1261 // Create the CanvasRenderingContext2D wrapper object. ··· 1259 1278 set_builtin_prop(ctx.gc, ctx.shapes, ctx_ref, "canvas", Value::Object(*r)); 1260 1279 } 1261 1280 1281 + // Register context2d methods on the wrapper. 1282 + let ctx2d_methods: &[NativeMethod] = &[ 1283 + ("save", ctx2d_save), 1284 + ("restore", ctx2d_restore), 1285 + ("translate", ctx2d_translate), 1286 + ("rotate", ctx2d_rotate), 1287 + ("scale", ctx2d_scale), 1288 + ("transform", ctx2d_transform), 1289 + ("setTransform", ctx2d_set_transform), 1290 + ("resetTransform", ctx2d_reset_transform), 1291 + ("getTransform", ctx2d_get_transform), 1292 + ]; 1293 + for &(name, callback) in ctx2d_methods { 1294 + let func = make_native(ctx.gc, name, callback); 1295 + set_builtin_prop(ctx.gc, ctx.shapes, ctx_ref, name, Value::Function(func)); 1296 + } 1297 + 1262 1298 Ok(Value::Object(ctx_ref)) 1299 + } 1300 + 1301 + // ── CanvasRenderingContext2D native methods ──────────────────────── 1302 + 1303 + /// `ctx.save()` — push the current drawing state onto the stack. 1304 + fn ctx2d_save(_args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 1305 + let node_id = get_canvas_node_id(ctx.gc, ctx.shapes, &ctx.this) 1306 + .ok_or_else(|| RuntimeError::type_error("save: not a canvas context"))?; 1307 + let bridge = ctx 1308 + .dom_bridge 1309 + .as_ref() 1310 + .ok_or_else(|| RuntimeError::type_error("no DOM bridge"))?; 1311 + let mut doc = bridge.document.borrow_mut(); 1312 + if let Some(c) = doc.canvas_contexts.get_mut(node_id) { 1313 + c.save(); 1314 + } 1315 + Ok(Value::Undefined) 1316 + } 1317 + 1318 + /// `ctx.restore()` — pop the most recently saved state from the stack. 1319 + fn ctx2d_restore(_args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 1320 + let node_id = get_canvas_node_id(ctx.gc, ctx.shapes, &ctx.this) 1321 + .ok_or_else(|| RuntimeError::type_error("restore: not a canvas context"))?; 1322 + let bridge = ctx 1323 + .dom_bridge 1324 + .as_ref() 1325 + .ok_or_else(|| RuntimeError::type_error("no DOM bridge"))?; 1326 + let mut doc = bridge.document.borrow_mut(); 1327 + if let Some(c) = doc.canvas_contexts.get_mut(node_id) { 1328 + c.restore(); 1329 + } 1330 + Ok(Value::Undefined) 1331 + } 1332 + 1333 + /// `ctx.translate(x, y)` — post-multiply the CTM by a translation. 1334 + fn ctx2d_translate(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 1335 + let node_id = get_canvas_node_id(ctx.gc, ctx.shapes, &ctx.this) 1336 + .ok_or_else(|| RuntimeError::type_error("translate: not a canvas context"))?; 1337 + let tx = args.first().map(|v| v.to_number()).unwrap_or(0.0); 1338 + let ty = args.get(1).map(|v| v.to_number()).unwrap_or(0.0); 1339 + let bridge = ctx 1340 + .dom_bridge 1341 + .as_ref() 1342 + .ok_or_else(|| RuntimeError::type_error("no DOM bridge"))?; 1343 + let mut doc = bridge.document.borrow_mut(); 1344 + if let Some(c) = doc.canvas_contexts.get_mut(node_id) { 1345 + c.translate(tx, ty); 1346 + } 1347 + Ok(Value::Undefined) 1348 + } 1349 + 1350 + /// `ctx.rotate(angle)` — post-multiply the CTM by a rotation (angle in radians). 1351 + fn ctx2d_rotate(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 1352 + let node_id = get_canvas_node_id(ctx.gc, ctx.shapes, &ctx.this) 1353 + .ok_or_else(|| RuntimeError::type_error("rotate: not a canvas context"))?; 1354 + let angle = args.first().map(|v| v.to_number()).unwrap_or(0.0); 1355 + let bridge = ctx 1356 + .dom_bridge 1357 + .as_ref() 1358 + .ok_or_else(|| RuntimeError::type_error("no DOM bridge"))?; 1359 + let mut doc = bridge.document.borrow_mut(); 1360 + if let Some(c) = doc.canvas_contexts.get_mut(node_id) { 1361 + c.rotate(angle); 1362 + } 1363 + Ok(Value::Undefined) 1364 + } 1365 + 1366 + /// `ctx.scale(x, y)` — post-multiply the CTM by a scale. 1367 + fn ctx2d_scale(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 1368 + let node_id = get_canvas_node_id(ctx.gc, ctx.shapes, &ctx.this) 1369 + .ok_or_else(|| RuntimeError::type_error("scale: not a canvas context"))?; 1370 + let sx = args.first().map(|v| v.to_number()).unwrap_or(1.0); 1371 + let sy = args.get(1).map(|v| v.to_number()).unwrap_or(1.0); 1372 + let bridge = ctx 1373 + .dom_bridge 1374 + .as_ref() 1375 + .ok_or_else(|| RuntimeError::type_error("no DOM bridge"))?; 1376 + let mut doc = bridge.document.borrow_mut(); 1377 + if let Some(c) = doc.canvas_contexts.get_mut(node_id) { 1378 + c.scale(sx, sy); 1379 + } 1380 + Ok(Value::Undefined) 1381 + } 1382 + 1383 + /// `ctx.transform(a, b, c, d, e, f)` — post-multiply the CTM by an arbitrary affine transform. 1384 + fn ctx2d_transform(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 1385 + let node_id = get_canvas_node_id(ctx.gc, ctx.shapes, &ctx.this) 1386 + .ok_or_else(|| RuntimeError::type_error("transform: not a canvas context"))?; 1387 + let a = args.first().map(|v| v.to_number()).unwrap_or(1.0); 1388 + let b = args.get(1).map(|v| v.to_number()).unwrap_or(0.0); 1389 + let c_val = args.get(2).map(|v| v.to_number()).unwrap_or(0.0); 1390 + let d = args.get(3).map(|v| v.to_number()).unwrap_or(1.0); 1391 + let e = args.get(4).map(|v| v.to_number()).unwrap_or(0.0); 1392 + let f = args.get(5).map(|v| v.to_number()).unwrap_or(0.0); 1393 + let bridge = ctx 1394 + .dom_bridge 1395 + .as_ref() 1396 + .ok_or_else(|| RuntimeError::type_error("no DOM bridge"))?; 1397 + let mut doc = bridge.document.borrow_mut(); 1398 + if let Some(cx) = doc.canvas_contexts.get_mut(node_id) { 1399 + cx.transform(a, b, c_val, d, e, f); 1400 + } 1401 + Ok(Value::Undefined) 1402 + } 1403 + 1404 + /// `ctx.setTransform(a, b, c, d, e, f)` — reset CTM then apply the given transform. 1405 + fn ctx2d_set_transform(args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 1406 + let node_id = get_canvas_node_id(ctx.gc, ctx.shapes, &ctx.this) 1407 + .ok_or_else(|| RuntimeError::type_error("setTransform: not a canvas context"))?; 1408 + let a = args.first().map(|v| v.to_number()).unwrap_or(1.0); 1409 + let b = args.get(1).map(|v| v.to_number()).unwrap_or(0.0); 1410 + let c_val = args.get(2).map(|v| v.to_number()).unwrap_or(0.0); 1411 + let d = args.get(3).map(|v| v.to_number()).unwrap_or(1.0); 1412 + let e = args.get(4).map(|v| v.to_number()).unwrap_or(0.0); 1413 + let f = args.get(5).map(|v| v.to_number()).unwrap_or(0.0); 1414 + let bridge = ctx 1415 + .dom_bridge 1416 + .as_ref() 1417 + .ok_or_else(|| RuntimeError::type_error("no DOM bridge"))?; 1418 + let mut doc = bridge.document.borrow_mut(); 1419 + if let Some(cx) = doc.canvas_contexts.get_mut(node_id) { 1420 + cx.set_transform(a, b, c_val, d, e, f); 1421 + } 1422 + Ok(Value::Undefined) 1423 + } 1424 + 1425 + /// `ctx.resetTransform()` — reset the CTM to identity. 1426 + fn ctx2d_reset_transform(_args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 1427 + let node_id = get_canvas_node_id(ctx.gc, ctx.shapes, &ctx.this) 1428 + .ok_or_else(|| RuntimeError::type_error("resetTransform: not a canvas context"))?; 1429 + let bridge = ctx 1430 + .dom_bridge 1431 + .as_ref() 1432 + .ok_or_else(|| RuntimeError::type_error("no DOM bridge"))?; 1433 + let mut doc = bridge.document.borrow_mut(); 1434 + if let Some(c) = doc.canvas_contexts.get_mut(node_id) { 1435 + c.reset_transform(); 1436 + } 1437 + Ok(Value::Undefined) 1438 + } 1439 + 1440 + /// `ctx.getTransform()` — return the current CTM as a DOMMatrix-like object. 1441 + fn ctx2d_get_transform(_args: &[Value], ctx: &mut NativeContext) -> Result<Value, RuntimeError> { 1442 + let node_id = get_canvas_node_id(ctx.gc, ctx.shapes, &ctx.this) 1443 + .ok_or_else(|| RuntimeError::type_error("getTransform: not a canvas context"))?; 1444 + let bridge = ctx 1445 + .dom_bridge 1446 + .as_ref() 1447 + .ok_or_else(|| RuntimeError::type_error("no DOM bridge"))?; 1448 + let doc = bridge.document.borrow(); 1449 + let t = match doc.canvas_contexts.get(node_id) { 1450 + Some(c) => c.get_transform(), 1451 + None => return Ok(Value::Undefined), 1452 + }; 1453 + 1454 + // Build a DOMMatrix-like object with a, b, c, d, e, f properties. 1455 + let mut matrix = ObjectData::new(); 1456 + for &(key, val) in &[ 1457 + ("a", t.a), 1458 + ("b", t.b), 1459 + ("c", t.c), 1460 + ("d", t.d), 1461 + ("e", t.e), 1462 + ("f", t.f), 1463 + ] { 1464 + matrix.insert_property( 1465 + key.to_string(), 1466 + Property::builtin(Value::Number(val)), 1467 + ctx.shapes, 1468 + ); 1469 + } 1470 + Ok(Value::Object(ctx.gc.alloc(HeapObject::Object(matrix)))) 1263 1471 } 1264 1472 1265 1473 // ── HTML serialization ────────────────────────────────────────────── ··· 6092 6300 .unwrap(); 6093 6301 match result { 6094 6302 Value::Boolean(b) => assert!(b, "ctx.canvas === c should be true"), 6303 + v => panic!("expected true, got {v:?}"), 6304 + } 6305 + } 6306 + 6307 + // ── Canvas 2D state & transform tests ───────────────────── 6308 + 6309 + #[test] 6310 + fn canvas_save_restore() { 6311 + let result = eval_with_doc( 6312 + r#"<html><body><canvas id="c"></canvas></body></html>"#, 6313 + r#" 6314 + var c = document.getElementById("c"); 6315 + var ctx = c.getContext("2d"); 6316 + ctx.translate(100, 50); 6317 + ctx.save(); 6318 + ctx.translate(10, 10); 6319 + var t1 = ctx.getTransform(); 6320 + ctx.restore(); 6321 + var t2 = ctx.getTransform(); 6322 + // After save+translate+restore, should be back to (100,50) 6323 + t1.e + "," + t1.f + "|" + t2.e + "," + t2.f 6324 + "#, 6325 + ) 6326 + .unwrap(); 6327 + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "110,60|100,50"); 6328 + } 6329 + 6330 + #[test] 6331 + fn canvas_translate() { 6332 + let result = eval_with_doc( 6333 + r#"<html><body><canvas id="c"></canvas></body></html>"#, 6334 + r#" 6335 + var c = document.getElementById("c"); 6336 + var ctx = c.getContext("2d"); 6337 + ctx.translate(100, 50); 6338 + var t = ctx.getTransform(); 6339 + t.e + "," + t.f 6340 + "#, 6341 + ) 6342 + .unwrap(); 6343 + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "100,50"); 6344 + } 6345 + 6346 + #[test] 6347 + fn canvas_scale() { 6348 + let result = eval_with_doc( 6349 + r#"<html><body><canvas id="c"></canvas></body></html>"#, 6350 + r#" 6351 + var c = document.getElementById("c"); 6352 + var ctx = c.getContext("2d"); 6353 + ctx.scale(2, 0.5); 6354 + var t = ctx.getTransform(); 6355 + t.a + "," + t.d 6356 + "#, 6357 + ) 6358 + .unwrap(); 6359 + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "2,0.5"); 6360 + } 6361 + 6362 + #[test] 6363 + fn canvas_rotate() { 6364 + let result = eval_with_doc( 6365 + r#"<html><body><canvas id="c"></canvas></body></html>"#, 6366 + r#" 6367 + var c = document.getElementById("c"); 6368 + var ctx = c.getContext("2d"); 6369 + ctx.rotate(Math.PI / 2); 6370 + var t = ctx.getTransform(); 6371 + // After 90° rotation: a≈0, b≈1, c≈-1, d≈0 6372 + var ok = Math.abs(t.a) < 0.0001 6373 + && Math.abs(t.b - 1) < 0.0001 6374 + && Math.abs(t.c + 1) < 0.0001 6375 + && Math.abs(t.d) < 0.0001; 6376 + ok ? "pass" : "fail:" + t.a + "," + t.b + "," + t.c + "," + t.d 6377 + "#, 6378 + ) 6379 + .unwrap(); 6380 + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "pass"); 6381 + } 6382 + 6383 + #[test] 6384 + fn canvas_set_transform_resets() { 6385 + let result = eval_with_doc( 6386 + r#"<html><body><canvas id="c"></canvas></body></html>"#, 6387 + r#" 6388 + var c = document.getElementById("c"); 6389 + var ctx = c.getContext("2d"); 6390 + ctx.translate(500, 500); 6391 + ctx.setTransform(1, 0, 0, 1, 0, 0); 6392 + var t = ctx.getTransform(); 6393 + t.a + "," + t.b + "," + t.c + "," + t.d + "," + t.e + "," + t.f 6394 + "#, 6395 + ) 6396 + .unwrap(); 6397 + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "1,0,0,1,0,0"); 6398 + } 6399 + 6400 + #[test] 6401 + fn canvas_reset_transform() { 6402 + let result = eval_with_doc( 6403 + r#"<html><body><canvas id="c"></canvas></body></html>"#, 6404 + r#" 6405 + var c = document.getElementById("c"); 6406 + var ctx = c.getContext("2d"); 6407 + ctx.scale(3, 3); 6408 + ctx.translate(50, 50); 6409 + ctx.resetTransform(); 6410 + var t = ctx.getTransform(); 6411 + t.a + "," + t.b + "," + t.c + "," + t.d + "," + t.e + "," + t.f 6412 + "#, 6413 + ) 6414 + .unwrap(); 6415 + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "1,0,0,1,0,0"); 6416 + } 6417 + 6418 + #[test] 6419 + fn canvas_transform_arbitrary() { 6420 + let result = eval_with_doc( 6421 + r#"<html><body><canvas id="c"></canvas></body></html>"#, 6422 + r#" 6423 + var c = document.getElementById("c"); 6424 + var ctx = c.getContext("2d"); 6425 + // Apply a shear: a=1, b=0, c=0.5, d=1, e=0, f=0 6426 + ctx.transform(1, 0, 0.5, 1, 0, 0); 6427 + var t = ctx.getTransform(); 6428 + t.a + "," + t.c 6429 + "#, 6430 + ) 6431 + .unwrap(); 6432 + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "1,0.5"); 6433 + } 6434 + 6435 + #[test] 6436 + fn canvas_nested_save_restore() { 6437 + let result = eval_with_doc( 6438 + r#"<html><body><canvas id="c"></canvas></body></html>"#, 6439 + r#" 6440 + var c = document.getElementById("c"); 6441 + var ctx = c.getContext("2d"); 6442 + ctx.translate(10, 0); 6443 + ctx.save(); 6444 + ctx.translate(20, 0); 6445 + ctx.save(); 6446 + ctx.translate(30, 0); 6447 + var e1 = ctx.getTransform().e; // 60 6448 + ctx.restore(); 6449 + var e2 = ctx.getTransform().e; // 30 6450 + ctx.restore(); 6451 + var e3 = ctx.getTransform().e; // 10 6452 + e1 + "," + e2 + "," + e3 6453 + "#, 6454 + ) 6455 + .unwrap(); 6456 + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "60,30,10"); 6457 + } 6458 + 6459 + #[test] 6460 + fn canvas_restore_empty_stack_noop() { 6461 + let result = eval_with_doc( 6462 + r#"<html><body><canvas id="c"></canvas></body></html>"#, 6463 + r#" 6464 + var c = document.getElementById("c"); 6465 + var ctx = c.getContext("2d"); 6466 + ctx.translate(42, 99); 6467 + ctx.restore(); // empty stack — should be no-op 6468 + var t = ctx.getTransform(); 6469 + t.e + "," + t.f 6470 + "#, 6471 + ) 6472 + .unwrap(); 6473 + assert_eq!(result.to_js_string(&crate::gc::Gc::new()), "42,99"); 6474 + } 6475 + 6476 + #[test] 6477 + fn canvas_get_transform_returns_object() { 6478 + let result = eval_with_doc( 6479 + r#"<html><body><canvas id="c"></canvas></body></html>"#, 6480 + r#" 6481 + var c = document.getElementById("c"); 6482 + var ctx = c.getContext("2d"); 6483 + var t = ctx.getTransform(); 6484 + // Identity matrix 6485 + typeof t === "object" 6486 + && t.a === 1 && t.b === 0 6487 + && t.c === 0 && t.d === 1 6488 + && t.e === 0 && t.f === 0 6489 + "#, 6490 + ) 6491 + .unwrap(); 6492 + match result { 6493 + Value::Boolean(b) => assert!(b, "getTransform should return identity DOMMatrix-like"), 6095 6494 v => panic!("expected true, got {v:?}"), 6096 6495 } 6097 6496 }