From 29f190a745140e3ab4b78c736a9519bc84442f17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=82=E3=81=99=E3=81=B1=E3=82=8B?= Date: Wed, 29 Jul 2026 17:44:05 +0900 Subject: [PATCH] fix(core): include child overflow in vector container paint bounds --- .../fixed_vector_container_paint_bounds.md | 9 + docs/layout-tutorial.ja.md | 2 +- docs/layout-tutorial.md | 2 +- tellur-core/src/layer.rs | 20 +- tellur-core/src/layout/flex.rs | 47 ++- tellur-core/src/layout/frame.rs | 19 +- tellur-core/src/layout/padding.rs | 27 +- tellur-core/tests/container_paint_bounds.rs | 289 ++++++++++++++++++ tellur-renderer/src/rasterize.rs | 48 +++ 9 files changed, 434 insertions(+), 29 deletions(-) create mode 100644 .changeset/fixed_vector_container_paint_bounds.md create mode 100644 tellur-core/tests/container_paint_bounds.rs diff --git a/.changeset/fixed_vector_container_paint_bounds.md b/.changeset/fixed_vector_container_paint_bounds.md new file mode 100644 index 0000000..edf19dd --- /dev/null +++ b/.changeset/fixed_vector_container_paint_bounds.md @@ -0,0 +1,9 @@ +--- +default: major +--- + +# Fixed vector containers clipping their children's overflow + +`VectorLayer`, `Frame`, `Flex`, and `Padding` reported only their own layout box as their paint bounds, so anything a child painted outside that box — an outset stroke, a drop shadow, an overhanging chip — was cut off during rasterization. They now start from the layout box and grow it to include their children's paint bounds, matching the raster variants of the same name. + +Existing projects whose layouts contain such overflow may produce different video pixels after upgrading, so regenerate and review reference renders or snapshots. diff --git a/docs/layout-tutorial.ja.md b/docs/layout-tutorial.ja.md index 280cd90..a253f91 100644 --- a/docs/layout-tutorial.ja.md +++ b/docs/layout-tutorial.ja.md @@ -322,6 +322,6 @@ use tellur_core::layout::{Frame, Flex, Flexible, Stack}; // vector use tellur_core::layout::raster::{Frame, Flex, Flexible, Stack}; // raster ``` -ソース上も 1 コンテナ 1 ファイル(`layout/frame.rs` など)に両変種が同居しています。raster 側だけの追加責務は `paint_bounds`(ドロップシャドウ等のはみ出しを含む描画範囲)と、キャッシュとの付き合い方(`Flexible` や `Positioned` は `CachePolicy::Transparent` で子にキャッシュスロットを譲る)です。 +ソース上も 1 コンテナ 1 ファイル(`layout/frame.rs` など)に両変種が同居しています。`paint_bounds`(外側にはみ出すストロークやドロップシャドウ等を含む描画範囲)は**両変種の共通責務**です。同じツリーなら両者は同じ矩形を返さねばならず、vector 側はさらにその矩形を `render().view_box` としても返す必要があります。したがって子をレイアウトするコンテナは、自身の `(0,0)..size` から出発して子の paint bounds を union します。例外は意図的に自分の箱へ pin するコンテナで、`DecoratedBox` がクリップ矩形として働くのはこのためです。raster 側だけの追加責務は、キャッシュとの付き合い方(`Flexible` や `Positioned` は `CachePolicy::Transparent` で子にキャッシュスロットを譲る)だけです。 例外が2つ: `Clip` は vector 専用(raster で欲しければ切ってから `.rasterize()`)、逆に `.opacity()` は raster 側にも同名で存在します(= `Opacity`。alpha 乗算のみで、`.transform()` / `.transform_around()` は今も vector 専用です)。 diff --git a/docs/layout-tutorial.md b/docs/layout-tutorial.md index 5c77695..74f066c 100644 --- a/docs/layout-tutorial.md +++ b/docs/layout-tutorial.md @@ -332,6 +332,6 @@ use tellur_core::layout::{Frame, Flex, Flexible, Stack}; // vector use tellur_core::layout::raster::{Frame, Flex, Flexible, Stack}; // raster ``` -In the source, both variants live in one file per container (`layout/frame.rs` etc.). The raster side's only extra responsibilities are `paint_bounds` (the drawn extent including overflow such as drop shadows) and its relationship with the cache (`Flexible` and `Positioned` use `CachePolicy::Transparent` to hand their cache slot to the child). +In the source, both variants live in one file per container (`layout/frame.rs` etc.). `paint_bounds` — the drawn extent, including overflow such as an outset stroke or a drop shadow — is a **shared** responsibility: for the same tree both variants must report the same rectangle, and the vector variant must return that same rectangle as its `render().view_box`. A container that lays out children therefore starts from its own `(0,0)..size` box and unions in every child's paint bounds; the exception is a container that pins its bounds to its own box on purpose, which is what makes `DecoratedBox` a clip rectangle. The raster side's only extra responsibility is its relationship with the cache (`Flexible` and `Positioned` use `CachePolicy::Transparent` to hand their cache slot to the child). Two exceptions: `Clip` is vector-only (for raster, cut first, then `.rasterize()`), and conversely `.opacity()` also exists on the raster side under the same name (= `Opacity`; alpha multiplication only — `.transform()` / `.transform_around()` remain vector-only). diff --git a/tellur-core/src/layer.rs b/tellur-core/src/layer.rs index 493cb8e..74b8ad5 100644 --- a/tellur-core/src/layer.rs +++ b/tellur-core/src/layer.rs @@ -59,13 +59,25 @@ impl VectorComponent for VectorLayer { constraints.constrain(self.size) } + fn paint_bounds(&self, size: Vec2) -> Rect { + // Start from the `(0,0)..size` rect and grow it to include any + // children that overflow the box. Mirrors the raster [`Layer`]. + let child_constraints = Constraints::loose(size); + let mut bounds = Rect { + origin: Vec2::ZERO, + size, + }; + for child in &self.children { + let child_size = child.layout(child_constraints); + bounds = union_rect(bounds, child.paint_bounds(child_size)); + } + bounds + } + fn render(&self, size: Vec2) -> VectorGraphic { render_vector_children( &self.children, - Rect { - origin: Vec2::ZERO, - size, - }, + self.paint_bounds(size), Constraints::loose(size), ) } diff --git a/tellur-core/src/layout/flex.rs b/tellur-core/src/layout/flex.rs index a31644b..9a11d9b 100644 --- a/tellur-core/src/layout/flex.rs +++ b/tellur-core/src/layout/flex.rs @@ -1,6 +1,7 @@ //! [`Flex`]: flexbox-style arrangement of children along one axis. use crate::geometry::{Axis, Constraints, Rect, Transform, Vec2}; +use crate::layer::{translate_rect, union_rect}; use crate::vector::{Group, Node, VectorComponent, VectorGraphic}; use crate::Keyable; @@ -74,6 +75,33 @@ impl Flex { }) .collect() } + + /// The child arrangement at an already-resolved `size`. Shared by + /// `paint_bounds` and `render` so both read the same placements. + fn resolved_pass(&self, size: Vec2) -> FlexPass { + compute_flex_pass( + self.axis, + self.spacing, + self.main_align, + self.cross_align, + Constraints::tight(size), + &self.child_grows(), + |i, c| self.children[i].layout(c), + ) + } + + /// The flex box grown to include any child paint that overflows it. + /// Mirrors the raster [`Flex`](raster::Flex). + fn pass_bounds(&self, pass: &FlexPass) -> Rect { + let mut bounds = Rect { + origin: Vec2::ZERO, + size: pass.own_size, + }; + for (child, &(pos, child_size)) in self.children.iter().zip(pass.children.iter()) { + bounds = union_rect(bounds, translate_rect(child.paint_bounds(child_size), pos)); + } + bounds + } } pub(super) struct FlexPass { @@ -252,16 +280,12 @@ impl VectorComponent for Flex { .own_size } + fn paint_bounds(&self, size: Vec2) -> Rect { + self.pass_bounds(&self.resolved_pass(size)) + } + fn render(&self, size: Vec2) -> VectorGraphic { - let pass = compute_flex_pass( - self.axis, - self.spacing, - self.main_align, - self.cross_align, - Constraints::tight(size), - &self.child_grows(), - |i, c| self.children[i].layout(c), - ); + let pass = self.resolved_pass(size); let nodes: Vec = self .children .iter() @@ -276,10 +300,7 @@ impl VectorComponent for Flex { }) .collect(); VectorGraphic { - view_box: Rect { - origin: Vec2::ZERO, - size: pass.own_size, - }, + view_box: self.pass_bounds(&pass), root: Node::Group(Group { transform: Transform::IDENTITY, opacity: 1.0, diff --git a/tellur-core/src/layout/frame.rs b/tellur-core/src/layout/frame.rs index e15a7cf..c0b6557 100644 --- a/tellur-core/src/layout/frame.rs +++ b/tellur-core/src/layout/frame.rs @@ -1,6 +1,7 @@ //! [`Frame`]: per-axis sizing for one top-left-aligned child. use crate::geometry::{Constraints, Rect, Transform, Vec2}; +use crate::layer::union_rect; use crate::vector::{Group, Node, VectorComponent, VectorGraphic}; use crate::Keyable; @@ -69,14 +70,24 @@ impl VectorComponent for Frame { }) } - fn render(&self, size: Vec2) -> VectorGraphic { + fn paint_bounds(&self, size: Vec2) -> Rect { + // Start from the `(0,0)..size` rect and grow it to include a child + // that overflows the box. Mirrors the raster [`Frame`](raster::Frame). let child_size = self.child.layout(Constraints::loose(size)); - let inner = self.child.render(child_size); - VectorGraphic { - view_box: Rect { + union_rect( + Rect { origin: Vec2::ZERO, size, }, + self.child.paint_bounds(child_size), + ) + } + + fn render(&self, size: Vec2) -> VectorGraphic { + let child_size = self.child.layout(Constraints::loose(size)); + let inner = self.child.render(child_size); + VectorGraphic { + view_box: self.paint_bounds(size), root: Node::Group(Group { transform: Transform::IDENTITY, opacity: 1.0, diff --git a/tellur-core/src/layout/padding.rs b/tellur-core/src/layout/padding.rs index 7d8634b..0381dbc 100644 --- a/tellur-core/src/layout/padding.rs +++ b/tellur-core/src/layout/padding.rs @@ -1,6 +1,7 @@ //! [`Padding`]: empty space around a child. use crate::geometry::{Constraints, EdgeInsets, Rect, Transform, Vec2}; +use crate::layer::{translate_rect, union_rect}; use crate::vector::{Group, Node, VectorComponent, VectorGraphic}; use crate::Keyable; @@ -17,6 +18,11 @@ impl Padding { fn inset_size(&self) -> Vec2 { Vec2(self.insets.horizontal(), self.insets.vertical()) } + + fn inner_size(&self, size: Vec2) -> Vec2 { + let inset = self.inset_size(); + Vec2((size.0 - inset.0).max(0.0), (size.1 - inset.1).max(0.0)) + } } impl VectorComponent for Padding { @@ -26,15 +32,24 @@ impl VectorComponent for Padding { Vec2(child_size.0 + inset.0, child_size.1 + inset.1) } - fn render(&self, size: Vec2) -> VectorGraphic { - let inset = self.inset_size(); - let inner_size = Vec2((size.0 - inset.0).max(0.0), (size.1 - inset.1).max(0.0)); - let inner = self.child.render(inner_size); - VectorGraphic { - view_box: Rect { + fn paint_bounds(&self, size: Vec2) -> Rect { + // Start from the `(0,0)..size` rect and grow it to include a child + // that overflows the padded box. Mirrors the raster + // [`Padding`](raster::Padding). + let child_paint = self.child.paint_bounds(self.inner_size(size)); + union_rect( + Rect { origin: Vec2::ZERO, size, }, + translate_rect(child_paint, self.insets.top_left()), + ) + } + + fn render(&self, size: Vec2) -> VectorGraphic { + let inner = self.child.render(self.inner_size(size)); + VectorGraphic { + view_box: self.paint_bounds(size), root: Node::Group(Group { transform: Transform::translate(self.insets.top_left()), opacity: 1.0, diff --git a/tellur-core/tests/container_paint_bounds.rs b/tellur-core/tests/container_paint_bounds.rs new file mode 100644 index 0000000..e8ca086 --- /dev/null +++ b/tellur-core/tests/container_paint_bounds.rs @@ -0,0 +1,289 @@ +//! Paint-bounds contract for every container, in both worlds. +//! +//! Each container comes in a vector and a raster variant with the same name +//! and the same semantics, so both must report the same paint bounds for the +//! same tree. A container that lays out children reports its layout box grown +//! by whatever its children paint outside it; otherwise the overflow is lost +//! when the tree is rasterized. `Rasterize` only checks that a vector's +//! `view_box` equals its own `paint_bounds`, so a container that silently +//! drops child overflow satisfies that check while losing ink — this file is +//! the check that catches it. +//! +//! Every container appears here exactly once: either as a container that keeps +//! child overflow, or, in `clipping_containers_pin_their_own_region`, as one +//! that deliberately pins its bounds to its own box and so doubles as a clip +//! rectangle. A new container that is not listed leaves the contract +//! unchecked on its side. +//! +//! Leaves and layout-transparent wrappers (`SizedBox`, `Positioned`, +//! `Transformed`, `Flexible`, the shapes) are covered by their own module +//! tests. + +use tellur_core::clip::{Clip, ClipRegion}; +use tellur_core::fragment::{raster as fragment_raster, Fragment}; +use tellur_core::geometry::{Axis, Constraints, EdgeInsets, Rect, Vec2}; +use tellur_core::layer::{Layer, VectorLayer}; +use tellur_core::layout::{ + raster as layout_raster, CrossAlign, DecoratedBox, Flex, Frame, MainAlign, Padding, SizeMode, + SizedBox, Stack, +}; +use tellur_core::raster::{RasterComponent, RasterImage, RasterResidency, Resolution}; +use tellur_core::render_context::RenderContext; +use tellur_core::vector::{Node, VectorComponent, VectorGraphic}; + +/// Layout size of the spilling child, and of the containers built around it. +const BOX: Vec2 = Vec2(100.0, 60.0); +/// How far the spilling child paints outside its layout box on every side. +const SPILL: f32 = 5.0; + +/// `(0,0)..size` grown by [`SPILL`] on every side — what a child that paints +/// outside its layout box (an outset stroke, a drop shadow) reports. +fn spilled(size: Vec2) -> Rect { + Rect { + origin: Vec2(-SPILL, -SPILL), + size: Vec2(size.0 + 2.0 * SPILL, size.1 + 2.0 * SPILL), + } +} + +/// A child whose paint bounds spill outside its layout box on every side. +#[derive(Clone, PartialEq, Eq, Hash)] +struct SpillingVector; + +impl VectorComponent for SpillingVector { + fn layout(&self, constraints: Constraints) -> Vec2 { + constraints.constrain(BOX) + } + + fn paint_bounds(&self, size: Vec2) -> Rect { + spilled(size) + } + + fn render(&self, size: Vec2) -> VectorGraphic { + VectorGraphic { + view_box: spilled(size), + root: Node::empty(), + } + } +} + +/// Raster mirror of [`SpillingVector`]. +#[derive(Clone, PartialEq, Eq, Hash)] +struct SpillingRaster; + +impl RasterComponent for SpillingRaster { + fn layout(&self, constraints: Constraints) -> Vec2 { + constraints.constrain(BOX) + } + + fn paint_bounds(&self, size: Vec2) -> Rect { + spilled(size) + } + + fn render( + &self, + _size: Vec2, + _target: Resolution, + _residency: RasterResidency, + _ctx: &mut dyn RenderContext, + ) -> RasterImage { + panic!("the paint-bounds contract never rasterizes") + } +} + +/// Asserts the vector container reports `expected` and keeps its view box in +/// step, as `Rasterize` requires. +fn check_vector(container: &dyn VectorComponent, size: Vec2, expected: Rect) { + assert_eq!( + container.paint_bounds(size), + expected, + "vector paint_bounds" + ); + assert_eq!( + container.render(size).view_box, + expected, + "vector view_box must match paint_bounds" + ); +} + +/// Asserts the raster twin agrees with its vector counterpart. +fn check_raster(container: &dyn RasterComponent, size: Vec2, expected: Rect) { + assert_eq!( + container.paint_bounds(size), + expected, + "raster paint_bounds" + ); +} + +#[test] +fn layer_keeps_child_overflow() { + check_vector( + &VectorLayer { + size: BOX, + children: vec![SpillingVector.boxed()], + }, + BOX, + spilled(BOX), + ); + check_raster( + &Layer { + size: BOX, + children: vec![SpillingRaster.boxed()], + }, + BOX, + spilled(BOX), + ); +} + +#[test] +fn fragment_keeps_child_overflow() { + // A fragment auto-fits its children, so its own box already *is* the spill. + let fragment = Fragment { + children: vec![SpillingVector.boxed()], + }; + check_vector( + &fragment, + fragment.layout(Constraints::UNBOUNDED), + spilled(BOX), + ); + + let raster = fragment_raster::Fragment { + children: vec![SpillingRaster.boxed()], + }; + check_raster(&raster, raster.layout(Constraints::UNBOUNDED), spilled(BOX)); +} + +#[test] +fn frame_keeps_child_overflow() { + check_vector( + &Frame { + width: SizeMode::Fixed(BOX.0), + height: SizeMode::Fixed(BOX.1), + child: SpillingVector.boxed(), + }, + BOX, + spilled(BOX), + ); + check_raster( + &layout_raster::Frame { + width: SizeMode::Fixed(BOX.0), + height: SizeMode::Fixed(BOX.1), + child: SpillingRaster.boxed(), + }, + BOX, + spilled(BOX), + ); +} + +#[test] +fn flex_keeps_child_overflow() { + // A lone child sits at the flex origin, so its spill pokes out on all sides. + let flex = Flex { + children: vec![SpillingVector.boxed()], + axis: Axis::Horizontal, + spacing: 0.0, + main_align: MainAlign::Start, + cross_align: CrossAlign::Start, + }; + check_vector(&flex, flex.layout(Constraints::loose(BOX)), spilled(BOX)); + + let raster = layout_raster::Flex { + children: vec![SpillingRaster.boxed()], + axis: Axis::Horizontal, + spacing: 0.0, + main_align: MainAlign::Start, + cross_align: CrossAlign::Start, + }; + check_raster( + &raster, + raster.layout(Constraints::loose(BOX)), + spilled(BOX), + ); +} + +#[test] +fn padding_keeps_child_overflow() { + // Insets narrower than the spill leave it poking out, and the child's + // bounds must be translated by the inset before the union. + let insets = EdgeInsets::all(2.0); + let size = Vec2(BOX.0 + 4.0, BOX.1 + 4.0); + let expected = Rect { + origin: Vec2(-3.0, -3.0), + size: Vec2(BOX.0 + 10.0, BOX.1 + 10.0), + }; + + let padding = Padding { + insets, + child: SpillingVector.boxed(), + }; + assert_eq!(padding.layout(Constraints::UNBOUNDED), size); + check_vector(&padding, size, expected); + check_raster( + &layout_raster::Padding { + insets, + child: SpillingRaster.boxed(), + }, + size, + expected, + ); +} + +#[test] +fn stack_keeps_overlay_overflow() { + // Only `base` sizes the stack; an overlay's spill still counts. + check_vector( + &Stack { + unders: Vec::new(), + overs: vec![SpillingVector.boxed()], + base: SizedBox { size: BOX }.boxed(), + }, + BOX, + spilled(BOX), + ); + check_raster( + &layout_raster::Stack { + unders: Vec::new(), + overs: vec![SpillingRaster.boxed()], + base: layout_raster::SizedBox { size: BOX }.boxed(), + }, + BOX, + spilled(BOX), + ); +} + +#[test] +fn clipping_containers_pin_their_own_region() { + let own_box = Rect { + origin: Vec2::ZERO, + size: BOX, + }; + + // `DecoratedBox` pins its bounds to its own box in both worlds, so it + // doubles as a clip rectangle for a child that paints outside it. + check_vector( + &DecoratedBox { + child: SpillingVector.boxed(), + background: None, + border: None, + }, + BOX, + own_box, + ); + check_raster( + &layout_raster::DecoratedBox { + child: SpillingRaster.boxed(), + background: None, + }, + BOX, + own_box, + ); + + // `Clip` cuts geometrically: its bounds are the region ∩ the child's paint. + check_vector( + &Clip { + region: ClipRegion::rect(own_box), + child: SpillingVector.boxed(), + }, + BOX, + own_box, + ); +} diff --git a/tellur-renderer/src/rasterize.rs b/tellur-renderer/src/rasterize.rs index ca9201c..9319357 100644 --- a/tellur-renderer/src/rasterize.rs +++ b/tellur-renderer/src/rasterize.rs @@ -340,6 +340,7 @@ mod tests { use crate::gpu::GpuRenderer; use tellur_core::fragment::Fragment; use tellur_core::geometry::{Anchor, Rect}; + use tellur_core::layer::VectorLayer; use tellur_core::layout::Stack; use tellur_core::placement::VectorPlacement; use tellur_core::raster::{CpuRasterImage, GpuSurface}; @@ -841,6 +842,53 @@ mod tests { ); } + #[test] + fn vector_layer_child_spill_survives_cpu_rasterization() { + let size = Vec2(10.0, 10.0); + let chip = |at: Vec2| { + Rectangle::builder() + .size(Vec2(2.0, 2.0)) + .fill(Paint::Solid(Color::rgb_u8(255, 0, 0))) + .build() + .place_at(at) + }; + // Children spilling off both corners, so the layer's paint bounds have + // to grow in the negative direction as well as past its own extent. + let layer = VectorLayer::builder() + .size(size) + .child( + Rectangle::builder() + .size(size) + .fill(Paint::Solid(Color::rgb_u8(0, 0, 255))), + ) + .child(chip(Vec2(-2.0, -2.0))) + .child(chip(size)) + .build(); + let expected_bounds = Rect { + origin: Vec2(-2.0, -2.0), + size: Vec2(14.0, 14.0), + }; + + assert_eq!(layer.layout(Constraints::tight(size)), size); + assert_eq!(layer.paint_bounds(size), expected_bounds); + assert_eq!(layer.render(size).view_box, expected_bounds); + + let image = layer.rasterize().render( + size, + Resolution::new(14, 14), + RasterResidency::Cpu, + &mut PassThrough, + ); + assert!( + alpha_at(&image, 0, 0, 14) > 0, + "the child painting above-left of the layer box must not be clipped", + ); + assert!( + alpha_at(&image, 13, 13, 14) > 0, + "the child painting past the layer box must not be clipped", + ); + } + #[test] fn non_positive_paint_bounds_return_a_transparent_target() { let component = Rasterize {