diff --git a/moli-layout/src/style.rs b/moli-layout/src/style.rs
index 22e58a4d67..f27d3d09e9 100644
--- a/moli-layout/src/style.rs
+++ b/moli-layout/src/style.rs
@@ -2385,14 +2385,14 @@ fn resolve_stylo_2d_transform(
// precision even if they are widened again for LayoutTransform2D.
.to_transform_3d_matrix_f64(Some(&reference_box))
{
- Ok((_matrix, true)) => {
- has_unsupported_3d = true;
- None
- }
- Ok((matrix, false)) => Some(LayoutTransform2D::new([
+ // Stylo's boolean reports 3D function syntax, not whether the
+ // resulting matrix needs 3D geometry. In particular, popovers
+ // commonly center with translate3d(-50%, 0, 0). Keep every matrix
+ // that is exactly representable in our 2D coordinate space.
+ Ok((matrix, _)) if matrix.is_2d() => Some(LayoutTransform2D::new([
matrix.m11, matrix.m12, matrix.m21, matrix.m22, matrix.m41, matrix.m42,
])),
- Err(_) => {
+ Ok(_) | Err(_) => {
has_unsupported_3d = true;
None
}
diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs
index 229142871c..eda088b39c 100644
--- a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs
+++ b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update.rs
@@ -1,5 +1,6 @@
use super::*;
+mod planar_transforms;
mod print;
mod transform_precision;
diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/planar_transforms.rs b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/planar_transforms.rs
new file mode 100644
index 0000000000..afe052ece0
--- /dev/null
+++ b/moli-renderer-v8/src/runtime/page_vm/tests/rendering_update/planar_transforms.rs
@@ -0,0 +1,135 @@
+use super::*;
+
+#[tokio::test(flavor = "current_thread")]
+async fn planar_3d_functions_match_chromium_popover_geometry_paint_and_hit_testing() {
+ run_page_vm_async_test(async move {
+ let loader =
+ crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader");
+ let mut page_vm = test_page_vm_with_loader_and_document_url(
+ &loader,
+ Vec::new(),
+ Url::parse("https://example.com/planar-transforms.html")?,
+ );
+ page_vm.vm_mut().eval(
+ r#"
+document.head.innerHTML = ``;
+document.body.innerHTML = `
+
+`;
+'installed'
+"#,
+ )?;
+ page_vm.vm_mut().sync_live_document_style_sources();
+ let viewport = moli_layout::PaintViewport::new(600, 400, 1.0);
+ let snapshot = page_vm
+ .vm_mut()
+ .screenshot_layout_snapshot(viewport)?
+ .expect("planar transform fixture must retain a layout root");
+ assert!(
+ snapshot
+ .diagnostics
+ .iter()
+ .all(|diagnostic| diagnostic.code != "transform-3d-geometry-fallback"),
+ "3D function syntax with a 2D matrix must not trigger a fallback"
+ );
+
+ let result = page_vm.vm_mut().eval(
+ r#"JSON.stringify((()=>{
+const ids=['bottom','bottom-popover','content','right','right-popover','composed','matrix','zero-z'];
+const rect=id=>{const r=document.getElementById(id).getBoundingClientRect();return [r.x,r.y,r.width,r.height]};
+return {geometry:Object.fromEntries(ids.map(id=>[id,rect(id)])),
+hits:[[50,75],[60,90],[80,210],[240,220],[370,240],[420,320],[410,80]].map(([x,y])=>document.elementFromPoint(x,y)?.id||null)};
+})())"#,
+ )?;
+ let result: serde_json::Value = serde_json::from_str(&result)?;
+ // Recorded in Chromium. Odd border-box sizes also pin the percentage
+ // reference box and preserve the half-pixel centering translation.
+ for (id, expected) in [
+ ("bottom", [200.0, 20.0, 50.0, 50.0]),
+ ("bottom-popover", [44.5, 70.0, 361.0, 80.0]),
+ ("content", [55.5, 81.0, 20.0, 20.0]),
+ ("right", [20.0, 220.0, 50.0, 50.0]),
+ ("right-popover", [70.0, 204.5, 120.0, 81.0]),
+ ("composed", [230.0, 200.0, 40.0, 160.0]),
+ ("matrix", [365.0, 235.0, 80.0, 40.0]),
+ ("zero-z", [415.0, 315.0, 40.0, 20.0]),
+ ] {
+ for (index, expected) in expected.into_iter().enumerate() {
+ let actual = result["geometry"][id][index]
+ .as_f64()
+ .expect("numeric geometry");
+ assert!(
+ (actual - expected).abs() <= 0.001,
+ "{id}[{index}]: expected {expected}, got {actual}"
+ );
+ }
+ }
+ assert_eq!(
+ result["hits"],
+ serde_json::json!([
+ "bottom-popover", "content", "right-popover", "composed", "matrix", "zero-z", null
+ ]),
+ );
+
+ let image = moli_paint::raster_snapshot(&snapshot)?;
+ let pixel = |x: u32, y: u32| {
+ let index = ((y * image.width + x) * 4) as usize;
+ <[u8; 4]>::try_from(&image.rgba[index..index + 4]).expect("RGBA pixel")
+ };
+ for (point, expected) in [
+ ((50, 75), [255, 0, 0, 255]),
+ ((60, 90), [0, 255, 0, 255]),
+ ((80, 210), [255, 0, 0, 255]),
+ ((240, 220), [128, 0, 128, 255]),
+ ((370, 240), [255, 165, 0, 255]),
+ ((420, 320), [0, 128, 128, 255]),
+ ((410, 80), [255, 255, 255, 255]),
+ ] {
+ assert_eq!(pixel(point.0, point.1), expected, "pixel at {point:?}");
+ }
+
+ // A nonzero z translation still cannot be represented in 2D. Retain
+ // the existing diagnostic and conservative geometry for that case.
+ page_vm.vm_mut().eval(
+ r#"
+const depth = document.createElement('div');
+depth.id = 'depth';
+depth.style.cssText = 'position:absolute;left:500px;top:20px;width:40px;height:20px;transform:translate3d(-10px,0,5px)';
+document.body.appendChild(depth);
+'installed'
+"#,
+ )?;
+ let snapshot = page_vm
+ .vm_mut()
+ .screenshot_layout_snapshot(viewport)?
+ .expect("depth fixture must retain a layout root");
+ assert_eq!(
+ snapshot
+ .diagnostics
+ .iter()
+ .filter(|diagnostic| diagnostic.code == "transform-3d-geometry-fallback")
+ .count(),
+ 1,
+ );
+ assert_eq!(
+ page_vm.vm_mut().eval(
+ "(()=>{const r=document.getElementById('depth').getBoundingClientRect();return [r.x,r.y,r.width,r.height].join('|')})()",
+ )?,
+ "500|20|40|20",
+ );
+ Ok::<_, anyhow::Error>(())
+ })
+ .await
+ .expect("planar transform fixture should run");
+}