Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/n0/src/drawlist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ pub(crate) struct ResolvedClipLayer {
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedClipPath {
pub(crate) layers: Arc<[ResolvedClipLayer]>,
/// Whether the backend should compute fractional edge coverage.
pub(crate) anti_alias: bool,
}

/// Drawlist projection of a resolved mask-source interpretation.
Expand Down
58 changes: 56 additions & 2 deletions crates/n0/src/glyphless.rs
Original file line number Diff line number Diff line change
Expand Up @@ -912,7 +912,10 @@ fn compile_clip_path(clip: &ClipPath) -> ResolvedClipPath {
})
.collect::<Vec<_>>()
.into();
ResolvedClipPath { layers }
ResolvedClipPath {
layers,
anti_alias: clip.edge_mode() == rframe::ClipEdgeMode::AntiAliased,
}
}

/// Project one checked, source-neutral filter program into private painter
Expand Down Expand Up @@ -2038,6 +2041,14 @@ mod tests {
}

fn clip_begin(owner: VisualRef, layers: Vec<Vec<(Rectangle, AffineTransform)>>) -> FrameItem {
clip_begin_with_edge(owner, layers, rframe::ClipEdgeMode::AntiAliased)
}

fn clip_begin_with_edge(
owner: VisualRef,
layers: Vec<Vec<(Rectangle, AffineTransform)>>,
edge_mode: rframe::ClipEdgeMode,
) -> FrameItem {
let layers = layers
.into_iter()
.map(|geometries| {
Expand All @@ -2054,7 +2065,8 @@ mod tests {
FrameItem::ScopeBegin(Scope {
owner,
effect: ScopeEffect::Clip(
rframe::ClipPath::new(layers).expect("test clip has at least one layer"),
rframe::ClipPath::new_with_edge_mode(layers, edge_mode)
.expect("test clip has at least one layer"),
),
})
}
Expand Down Expand Up @@ -3363,6 +3375,48 @@ mod tests {
assert_eq!(rgba_at(&pixels, 64, 12, 24), [0xff, 0xff, 0xff, 0xff]);
}

#[test]
fn hard_clip_edge_policy_reaches_the_painter() {
let scene = |edge_mode| {
let items = FrameItems::try_new(vec![
clip_begin_with_edge(
SCOPE_OWNER,
vec![vec![(
Rectangle::from_xywh(8.25, 6.25, 20.0, 16.0),
AffineTransform::identity(),
)]],
edge_mode,
),
FrameItem::Node(rect_node(
RECT_OWNER,
Rectangle::from_xywh(0.0, 0.0, 64.0, 48.0),
0xFF16_A34A,
)),
FrameItem::ScopeEnd,
])
.expect("balanced clip scope");
compile(frame_of(items)).expect("admitted clip scene")
};

let ordinary = scene(rframe::ClipEdgeMode::AntiAliased);
let hard = scene(rframe::ClipEdgeMode::Hard);
let ItemKind::BeginClipPath { clip } = &hard.drawlist.items[1].kind else {
panic!("resolved clip item")
};
assert!(!clip.anti_alias, "hard policy survives contract lowering");

let raster = |product: &FrameProduct| {
product
.raster_to_bytes(&AffineTransform::identity(), 64, 48, &PaintCtx::new(None))
.expect("resource-free clip raster")
};
assert_ne!(
raster(&ordinary),
raster(&hard),
"the backend edge flag changes fractional clip coverage"
);
}

#[test]
fn an_empty_clip_layer_is_the_checked_clip_all_fact() {
let items = FrameItems::try_new(vec![
Expand Down
2 changes: 1 addition & 1 deletion crates/n0/src/paint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3898,7 +3898,7 @@ pub fn execute_unchecked<K>(canvas: &Canvas, list: &DrawList<K>, view: &Affine,
.expect("geometric clip path operations were preflighted at product build");
canvas.save();
canvas.set_matrix(&skia_matrix(&total).into());
canvas.clip_path(&path, ClipOp::Intersect, true);
canvas.clip_path(&path, ClipOp::Intersect, clip.anti_alias);
scopes.push(Scope::Clip);
}
ItemKind::EndClip => {
Expand Down
49 changes: 47 additions & 2 deletions crates/n0_cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -539,8 +539,8 @@ cargo run -p n0_cli --bin n0 -- \
The filter estate contains 26 chassis/blur cells, 60 shadow-graph, 28 native
drop-shadow, 27 color-matrix, 32 component-transfer, 38 blend, 37 morphology,
91 turbulence/displacement, 41 convolution-rung, and 71 diffuse-lighting
cells. The complete corpus contains 936 Chromium-baked cells plus 16 sampled
frames, with 172 named
cells. The complete corpus contains 1,026 Chromium-baked cells plus 16 sampled
frames, with 189 named
refusal rows. `feFlood`, `feComposite`,
`feMerge`, `feMergeNode`, `feDropShadow`, `feColorMatrix`,
`feComponentTransfer`, `feBlend`, `feMorphology`, `feConvolveMatrix`,
Expand Down Expand Up @@ -851,6 +851,51 @@ cargo run -p n0_cli --bin n0 -- \
formerly admitted only by Rust, and one valid midpoint-adjacent decimal
formerly selected the wrong binary32 neighbour. Three focused refusal rows
guard those classes. The `<pattern>` and `<animate>` rows remain open.
`<marker>` is consumed in one bounded, same-document static profile.
Direct inherited `marker-start`, `marker-mid`, and `marker-end` references
apply to `<line>`, `<path>`, `<polyline>`, and `<polygon>`; `none`, malformed
hints, missing or wrong-kind targets, first-id lookup, quoted/escaped URL
forms, and `<use>` clients follow Chromium's measured selection behavior.
CSS marker properties remain authored-ingress refusals because the pinned
Stylo build has no marker longhands. Chromium's bare `marker` attribute and
marker attributes on rect/circle/ellipse are inert and celled as such.
Placement retains authored vertex topology independently of raster path
decomposition: start and end belong to the whole path, later subpath moves
are mids, close back-patches the start tangent and contributes its duplicate
vertex, and one authored cubic, quadratic, or arc contributes one marker
edge. Move-only, one-point, valid-prefix, zero-length, degenerate-tangent,
angle-wrap, and exact-opposite cases are baked.
The marker viewport admits numeric and percentage `markerWidth`,
`markerHeight`, `refX`, and `refY`, plus `px`; both `markerUnits` branches;
explicit `orient` angles in every listed angle unit, `auto`, and
`auto-start-reverse`; and `viewBox` through the complete admitted
`preserveAspectRatio` mapper. Length percentages use the outer SVG viewport
axes even when the marker has a `viewBox`, as Chromium measures. Invalid or
non-positive viewport geometry produces the measured nothing. The marker
viewport is a hard clip, not ordinary anti-aliased clip-path coverage.
A source may contain the admitted solid/context-solid shapes and groups with
transforms. Client transforms, computed stroke-width scaling even when the
stroke paint is none, opacity, clip, mask, filter, root mapping, and context
paint retain their measured order. Every instance lowers to ordinary
source-neutral frame nodes and hard geometric clip scopes; no marker,
resource id, URL, or authored topology crosses `rframe`.
Marker opacity composition begins only when an authored vertex kind selects
an actual marker resource. Missing ids, wrong-kind targets, and a valid
marker property for a vertex kind the client does not have are
Chromium-equivalent to `none`; a selected real marker still chooses the
combined span when its viewport is zero or its valid source is empty. Five
exact cells guard this distinction.
What refuses by stable marker name: external URLs; author CSS around a used
marker; CSS math, custom properties, wider units, and CSS-wide resource
values; resource-root overflow, effects, opacity, transform, or an inherited
rendering declaration; source
dynamics, paint servers, effects, nested markers, text, and `<use>`; and the
checked position/source/client fan-out limits. Source compilation is one
transaction: strict refuses, while best effort removes the complete client
span rather than leaking its ordinary fill or stroke. Ninety exact
Chromium cells carry M1. The element, three presentation attributes, four
CSS property rows, and six marker-resource attribute rows remain open for
the wider source, cascade, grammar, dynamics, and external-I/O surface.
`<text>` is consumed (the text rung), and its font environment is the
host's: text resolves only against fonts declared with
`--font FAMILY=PATH@sha256:HEX` (repeatable), whose bytes are **verified
Expand Down
26 changes: 14 additions & 12 deletions crates/rframe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ producer (e.g. websem, from SVG)

## What it holds

| Module | Ownership |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `frame` | `Frame`, `FrameNode`, `Geometry`, leaf paint stacks or checked repeating vector programs, their post-paint alpha factor, and product identity |
| `path` | `PathData` — checked absolute commands, fill rule, tight bounds solved once |
| `stroke` | `Stroke` — centred width, cap, join, miter limit, optional checked dash pattern, and finite `f64` `outset` |
| `scope` | A checked painter-order scope stream: isolated opacity or source-neutral geometric clipping |
| `clip` | `ClipPath` — bounded path unions intersected in layers, with resolved transforms and conservative bounds |
| Module | Ownership |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `frame` | `Frame`, `FrameNode`, `Geometry`, leaf paint stacks or checked repeating vector programs, their post-paint alpha factor, and product identity |
| `path` | `PathData` — checked absolute commands, fill rule, tight bounds solved once |
| `stroke` | `Stroke` — centred width, cap, join, miter limit, optional checked dash pattern, and finite `f64` `outset` |
| `scope` | A checked painter-order scope stream: isolated opacity or source-neutral geometric clipping |
| `clip` | `ClipPath` — bounded path unions intersected in layers, with resolved transforms, conservative bounds, and an explicit anti-aliased or hard edge policy |

Two details are load-bearing enough to state here. A node's `bounds` is the
**geometry's** box, never the ink's: a stroke paints outside it, so a consumer
Expand Down Expand Up @@ -89,11 +89,13 @@ owns the transform. Path-length calibration remains inexpressible here rather
than being ignored or approximated. Geometry is rect, ellipse or path.
A geometric clip reuses that vocabulary after its source has resolved every
resource lookup and coordinate system: one layer unions contributors, and
layers intersect. It carries no URL, element, paint, alpha mask, or backend
path; text/raster fallback and image-backed masks remain inexpressible. Groups
as authored first-class nodes are likewise absent. A producer that meets one of
those boundaries must refuse or declare it rather than lower it into something
this contract cannot hold.
layers intersect. Its edge policy is one resolved raster fact: ordinary
coverage is anti-aliased by default, while a producer may explicitly require a
hard boundary. It carries no URL, element, paint, alpha mask, backend path, or
source reason for that policy; text/raster fallback and image-backed masks
remain inexpressible. Groups as authored first-class nodes are likewise absent.
A producer that meets one of those boundaries must refuse or declare it rather
than lower it into something this contract cannot hold.

Why this shape is chosen, and where the renderer joins it, is recorded in
[docs/wg/consolidation/n0-join-point.md](../../docs/wg/consolidation/n0-join-point.md).
33 changes: 32 additions & 1 deletion crates/rframe/src/clip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ pub const MAX_CLIP_GEOMETRIES_PER_LAYER: usize = 42;
/// The maximum number of chained clip layers in one resolved effect.
pub const MAX_CLIP_LAYERS: usize = 64;

/// Raster edge policy for a resolved geometric clip.
///
/// Ordinary SVG `clip-path` coverage is anti-aliased. Viewport clips are hard
/// pixel masks in Chromium, including after an affine mapping; carrying that
/// distinction here avoids smuggling source vocabulary into the consumer.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ClipEdgeMode {
#[default]
AntiAliased,
Hard,
}

/// Why resolved clip geometry cannot cross the contract.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ClipGeometryError {
Expand Down Expand Up @@ -192,10 +204,18 @@ impl std::error::Error for ClipPathError {}
pub struct ClipPath {
layers: Arc<[ClipLayer]>,
bounds: Option<Rectangle>,
edge_mode: ClipEdgeMode,
}

impl ClipPath {
pub fn new(layers: impl Into<Arc<[ClipLayer]>>) -> Result<Self, ClipPathError> {
Self::new_with_edge_mode(layers, ClipEdgeMode::AntiAliased)
}

pub fn new_with_edge_mode(
layers: impl Into<Arc<[ClipLayer]>>,
edge_mode: ClipEdgeMode,
) -> Result<Self, ClipPathError> {
let layers = layers.into();
if layers.is_empty() {
return Err(ClipPathError::NoLayers);
Expand All @@ -212,7 +232,11 @@ impl ClipPath {
_ => None,
};
}
Ok(Self { layers, bounds })
Ok(Self {
layers,
bounds,
edge_mode,
})
}

#[must_use]
Expand All @@ -226,6 +250,13 @@ impl ClipPath {
pub const fn bounds(&self) -> Option<Rectangle> {
self.bounds
}

/// Whether the clip edge carries ordinary coverage AA or a hard viewport
/// boundary.
#[must_use]
pub const fn edge_mode(&self) -> ClipEdgeMode {
self.edge_mode
}
}

fn valid_rectangle(rectangle: Rectangle) -> bool {
Expand Down
4 changes: 2 additions & 2 deletions crates/rframe/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ mod scope;
mod stroke;

pub use clip::{
ClipGeometry, ClipGeometryError, ClipLayer, ClipLayerError, ClipPath, ClipPathError,
MAX_CLIP_GEOMETRIES_PER_LAYER, MAX_CLIP_LAYERS,
ClipEdgeMode, ClipGeometry, ClipGeometryError, ClipLayer, ClipLayerError, ClipPath,
ClipPathError, MAX_CLIP_GEOMETRIES_PER_LAYER, MAX_CLIP_LAYERS,
};
pub use filter::{
Filter, FilterBlend, FilterChannelTables, FilterColorSpace, FilterComposite,
Expand Down
16 changes: 14 additions & 2 deletions crates/rframe/tests/clip_contract.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use math2::Rectangle;
use math2::transform::AffineTransform;
use rframe::{
ClipGeometry, ClipGeometryError, ClipLayer, ClipLayerError, ClipPath, ClipPathError, Geometry,
MAX_CLIP_GEOMETRIES_PER_LAYER, MAX_CLIP_LAYERS,
ClipEdgeMode, ClipGeometry, ClipGeometryError, ClipLayer, ClipLayerError, ClipPath,
ClipPathError, Geometry, MAX_CLIP_GEOMETRIES_PER_LAYER, MAX_CLIP_LAYERS,
};

fn rect(x: f32, y: f32, width: f32, height: f32) -> ClipGeometry {
Expand Down Expand Up @@ -41,6 +41,18 @@ fn an_empty_layer_is_a_valid_clip_all_fact() {
assert_eq!(clip.bounds(), None);
}

#[test]
fn edge_policy_is_explicit_and_ordinary_clips_stay_antialiased() {
let layer = || ClipLayer::new(vec![rect(0.0, 0.0, 10.0, 10.0)]).unwrap();
let ordinary = ClipPath::new(vec![layer()]).unwrap();
assert_eq!(ordinary.edge_mode(), ClipEdgeMode::AntiAliased);

let hard = ClipPath::new_with_edge_mode(vec![layer()], ClipEdgeMode::Hard).unwrap();
assert_eq!(hard.edge_mode(), ClipEdgeMode::Hard);
assert_eq!(hard.layers(), ordinary.layers());
assert_eq!(hard.bounds(), ordinary.bounds());
}

#[test]
fn geometry_checks_local_and_transformed_finiteness() {
assert_eq!(
Expand Down
Loading
Loading