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: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ python3 bin/activate-flatc -- --rust -o crates/grida/src/io/generated format/gri
| `crates/grida_dev` | dev CLI, benchmarks, reftest tooling |
| `crates/grida_wpt` | web-platform-tests harness |
| `crates/n0` · `n0-model` · `n0_dev` | the v2 engine family (the `anchor` model): skia-free model crate, resolve→drawlist→paint engine, winit/egui dev shell — promoted from the `model-v2-anchor` branch (gridaco/nothing#9) |
| `crates/websem` | the Web semantic compiler: an SVG or HTML source → one namespace-aware document → one Stylo cascade → `rframe::Frame`. Owns no parser and no painter; decides what the engine will and will not render |
| `crates/websem` | the Web semantic compiler: an SVG or HTML source → one namespace-aware document → one Stylo cascade → `rframe::Frame`. Owns no document parser and no painter; decodes admitted SVG attribute-value grammars while deciding what the engine will and will not render |
| `crates/rframe` | the resolved render contract (`Frame`): the visual facts a producer states after resolving its source. Contract-only and backend-free — no document, no cascade, no paint call |
| `crates/animation-sampling` | the time axis: Base or one exact signed-nanosecond Sample, with no ambient clock |
| `crates/textlayout` | the Web family's text resolution oracle ([the text-layout RFD](./docs/wg/feat-paragraph/text-layout.md) at its v0 profile): attributed text + a declared font environment → one immutable resolved layout, or a typed refusal. Owns no font discovery, no render contract, no clock — and is *not* an engine-wide text service **by decision**: the D-M text stage joined low (2026-08-05), so each engine keeps its own text artifact and `rframe` carries no text fact ([the text-stage evidence](./docs/wg/consolidation/n0-join-point.md#the-text-stage-evidence)) |
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,5 +193,7 @@ Chromium's rendering architecture. Start at
[the consolidation program](./docs/wg/consolidation/index.md) for how the two
engines become one.

Rust-first Cargo workspace. Licensed under [MIT](./LICENSE-MIT) or
[Apache-2.0](./LICENSE-APACHE), at your option.
Rust-first Cargo workspace. Project-authored code is licensed under
[MIT](./LICENSE-MIT) or [Apache-2.0](./LICENSE-APACHE), at your option.
Third-party material remains under its identified notice or license terms,
including the [Skia-derived Web geometry metric](./crates/websem/NOTICE.md).
43 changes: 32 additions & 11 deletions crates/csscascade/tests/svg_presentation_hints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ const STANDALONE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" width="64"
style="stroke-dashoffset: not-a-length" width="8" height="8"/>
<rect id="dashoffset-invalid-hint" stroke-dashoffset="not-a-length" width="8" height="8"/>
<g stroke-dashoffset="-4"><rect id="dashoffset-inherited" width="8" height="8"/></g>
<rect id="unadmitted" pathLength="100" width="8" height="8"/>
<rect id="pathlength-not-cascaded" pathLength="100" width="8" height="8"/>
<rect id="pathlength-control" width="8" height="8"/>
<g id="sized" font-size="32"><rect id="em-basis" stroke-width="0.5em" width="8" height="8"/></g>
<rect id="hidden-hint" visibility="hidden" width="8" height="8"/>
<rect id="display-none-hint" display="none" width="8" height="8"/>
Expand Down Expand Up @@ -204,16 +205,36 @@ fn standalone_svg_presentation_hints_enter_below_author_rules() {
property(root, "stroke-rule-beats-hint", LonghandId::Stroke),
"rgb(37, 99, 235)"
);
// An unadmitted presentation attribute still contributes nothing —
// `pathLength` has no CSS longhand at all, and the block synthesizer
// must skip it rather than invent one. (The websem compiler refuses
// rendering-relevant unadmitted attributes by name, so a document
// carrying one is a declared hole, not a silent one.)
assert_eq!(
property(root, "unadmitted", LonghandId::StrokeOpacity),
"1",
"unadmitted presentation attributes must not leak into the cascade"
);
// Pinned Stylo has no `path-length` longhand, so the presentation-hint
// block cannot synthesize one for `pathLength`. The Web compiler consumes
// that geometry attribute directly; the cascade must keep skipping it
// rather than inventing a second matcher or aliasing it to another fact.
for (name, longhand) in [
("fill", LonghandId::Fill),
("fill-rule", LonghandId::FillRule),
("stroke", LonghandId::Stroke),
("stroke-width", LonghandId::StrokeWidth),
("stroke-linecap", LonghandId::StrokeLinecap),
("stroke-linejoin", LonghandId::StrokeLinejoin),
("stroke-dasharray", LonghandId::StrokeDasharray),
("stroke-dashoffset", LonghandId::StrokeDashoffset),
("stroke-miterlimit", LonghandId::StrokeMiterlimit),
("font-size", LonghandId::FontSize),
("font-family", LonghandId::FontFamily),
("display", LonghandId::Display),
("visibility", LonghandId::Visibility),
("fill-opacity", LonghandId::FillOpacity),
("stroke-opacity", LonghandId::StrokeOpacity),
("opacity", LonghandId::Opacity),
("color", LonghandId::Color),
("transform", LonghandId::Transform),
] {
assert_eq!(
property(root, "pathlength-not-cascaded", longhand),
property(root, "pathlength-control", longhand),
"pathLength must not leak into the `{name}` computed longhand"
);
}
// The text rung's hint: `font-family` is what a run resolves against.
// Measured in Chromium: the attribute alone selects the face, an author
// rule beats it, `font-family=""` drops to the default family, and the
Expand Down
11 changes: 11 additions & 0 deletions crates/n0/src/drawlist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,17 @@ pub enum ItemKind {
stroke: Stroke,
dash_phase: StrokeDashPhase,
},
/// A dashed resolved-frame ellipse whose absolute local coordinates must
/// survive until Skia constructs the oval path effect. Folding `(x, y)`
/// into `world` first is algebraically equal but not f32-equivalent.
AbsoluteDashedOvalStroke {
x: f32,
y: f32,
w: f32,
h: f32,
stroke: Stroke,
dash_phase: StrokeDashPhase,
},
LineStroke {
x1: f32,
y1: f32,
Expand Down
172 changes: 152 additions & 20 deletions crates/n0/src/glyphless.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,15 +317,17 @@ pub fn compile(resolved: Frame) -> Result<FrameProduct, BuildError> {
if node.bounds != math2::rect_transform(rect, &node.transform) {
return Err(BuildError::VisualBoundsMismatch(node.owner));
}
// The paint reference box is the geometry's own extent. A box primitive
// draws at its item's origin, so its paint box already starts there. A
// path's stream carries absolute local coordinates, so its box starts
// at the tight-bounds origin instead — the painter's unit box does not,
// and the difference is observable only by a non-solid paint. The
// origin travels as a unit-space pre-translate on each gradient's
// The paint reference box is the geometry's own extent. Ordinary box
// routes draw at their item origin, so their paint box already starts
// there. A path's stream carries absolute local coordinates, so its box
// starts at the tight-bounds origin instead — the painter's unit box
// does not, and the difference is observable only by a non-solid paint.
// The origin travels as a unit-space pre-translate on each gradient's
// transform: box(x,y,w,h) × T = box(0,0,w,h) × translate(x/w, y/h) × T.
// A degenerate axis skips the fold; the producer resolves or refuses
// gradients on degenerate geometry before they reach this compile.
// gradients on degenerate geometry before they reach this compile. The
// dashed-ellipse exception below instead gives the painter its exact
// positioned paint box and therefore needs no compensating arithmetic.
let unit_offset = match &node.geometry {
Geometry::Path(_) if rect.width > 0.0 && rect.height > 0.0 => {
Some((rect.x / rect.width, rect.y / rect.height))
Expand All @@ -338,12 +340,14 @@ pub fn compile(resolved: Frame) -> Result<FrameProduct, BuildError> {
);
provenance.owners.push(node.owner);

// A box primitive draws at its item's origin, so its own local offset
// enters the world transform. A path carries absolute local
// An ordinary box route draws at its item's origin, so its own local
// offset enters the world transform. A path carries absolute local
// coordinates instead: its stream is the geometry, and translating it
// would be a second coordinate mapping over values the contract has
// already resolved.
let world = match &node.geometry {
// already resolved. The dashed-ellipse stroke below bypasses this box
// transform for the same exact-coordinate reason; its independent fill
// still uses this ordinary route.
let box_world = match &node.geometry {
Geometry::Rect(_) | Geometry::Ellipse(_) => {
to_affine(node.transform).then(&Affine::translate(rect.x, rect.y))
}
Expand All @@ -360,7 +364,7 @@ pub fn compile(resolved: Frame) -> Result<FrameProduct, BuildError> {
(None, false) => bounded_geometry_coverage(node.bounds, resolved.bounds),
(Some(stroke), _) => {
let box_world = matches!(&node.geometry, Geometry::Rect(_) | Geometry::Ellipse(_))
.then_some(&world);
.then_some(&box_world);
bounded_stroke_coverage(
rect,
&node.transform,
Expand Down Expand Up @@ -402,14 +406,27 @@ pub fn compile(resolved: Frame) -> Result<FrameProduct, BuildError> {
};
items.push(Item {
node: owner,
world,
world: box_world,
kind,
});
}
// SVG's default paint order is fill, then stroke — one item after the
// other in the same private drawlist, which is why a stroke needs no
// group scope.
if let Some(stroke) = &node.stroke {
// A resolved dashed oval must preserve the exact local conic
// stream over which its producer resolved the dash facts. Skia's
// path measurement and dash traversal are f32
// translation-sensitive, so moving the ellipse's box origin into
// `world` first is not equivalent: the interval and phase facts
// are unchanged, but their antialiased endpoints drift. Preserve
// the contract's absolute local coordinates for every live dashed
// ellipse. Solid ellipses remain the private oval primitive, and
// fills keep their existing independent box route.
let dashed_ellipse = matches!(&node.geometry, Geometry::Ellipse(_))
&& rect.width > 0.0
&& rect.height > 0.0
&& stroke.dash().is_some();
let (stroke, dash_phase) = compile_stroke(stroke, unit_offset);
let kind = match &node.geometry {
Geometry::Rect(_) => ItemKind::RectStroke {
Expand All @@ -420,6 +437,14 @@ pub fn compile(resolved: Frame) -> Result<FrameProduct, BuildError> {
stroke,
dash_phase,
},
Geometry::Ellipse(_) if dashed_ellipse => ItemKind::AbsoluteDashedOvalStroke {
x: rect.x,
y: rect.y,
w,
h,
stroke,
dash_phase,
},
Geometry::Ellipse(_) => ItemKind::OvalStroke {
w,
h,
Expand All @@ -436,7 +461,11 @@ pub fn compile(resolved: Frame) -> Result<FrameProduct, BuildError> {
};
items.push(Item {
node: owner,
world,
world: if dashed_ellipse {
to_affine(node.transform)
} else {
box_world
},
kind,
});
}
Expand Down Expand Up @@ -889,12 +918,14 @@ fn bounded_stroke_coverage(
let projected = direct.and_then(|direct| match box_world {
None => Some(direct),
Some(world) => {
// Rects and ellipses do not send their absolute local coordinates
// to the painter. Their origin is first folded into `world` in
// f32, then the painter maps a 0..w / 0..h box through that composed
// matrix. Those operations are mathematically equivalent to the
// direct projection above but not rounding-equivalent, so retain
// both. Paths keep the direct absolute-coordinate route only.
// Rects and ordinary origin-relative ellipses fold their local
// origin into `world` before painting. A live dashed ellipse takes
// the absolute-coordinate route instead; retaining this box
// projection for it is a deliberate conservative union, not a
// second claim about the pixels it paints. The two projections are
// mathematically equivalent but not rounding-equivalent, so damage
// keeps both until generic envelope work can treat every route
// uniformly. Paths keep the direct absolute-coordinate route only.
let box_transform = math2::transform::AffineTransform::from_acebdf(
world.a, world.c, world.e, world.b, world.d, world.f,
);
Expand Down Expand Up @@ -1214,6 +1245,7 @@ mod tests {
.find_map(|item| match &item.kind {
ItemKind::RectStroke { stroke, .. }
| ItemKind::OvalStroke { stroke, .. }
| ItemKind::AbsoluteDashedOvalStroke { stroke, .. }
| ItemKind::PathStroke { stroke, .. } => Some(stroke),
_ => None,
})
Expand All @@ -1228,6 +1260,7 @@ mod tests {
.find_map(|item| match &item.kind {
ItemKind::RectStroke { dash_phase, .. }
| ItemKind::OvalStroke { dash_phase, .. }
| ItemKind::AbsoluteDashedOvalStroke { dash_phase, .. }
| ItemKind::PathStroke { dash_phase, .. } => Some(*dash_phase),
_ => None,
})
Expand Down Expand Up @@ -1965,6 +1998,105 @@ mod tests {
);
}

/// A live dashed ellipse is the one box route whose local origin must not
/// be folded into the private world: Skia measures and slices conics in
/// f32, so translating first changes dash endpoints. The private item
/// retains the absolute local oval while a solid ellipse remains the
/// ordinary origin-relative primitive.
#[test]
fn dashed_ellipse_preserves_absolute_local_coordinates_until_paint() {
let rect = Rectangle::from_xywh(16.0, 8.0, 32.0, 24.0);
let intervals = rframe::StrokeDashIntervals::new(vec![6.0, 3.0])
.expect("test dash intervals are valid")
.expect("test dash cycle is present");
let dash = rframe::StrokeDash::new(intervals, 0.0).expect("test dash phase is finite");
let gradient = CgPaint::LinearGradient(cg::LinearGradientPaint::from_colors(vec![
CGColor::BLACK,
CGColor::WHITE,
]));
let paints = PaintStack::try_from_paints(CgPaints::new([gradient]))
.expect("test gradient is admitted");
let stroke = rframe::Stroke::new_with_dash(
paints,
8.0,
rframe::StrokeCap::Round,
rframe::StrokeJoin::Miter,
4.0,
Some(dash),
)
.expect("test stroke is valid")
.expect("test stroke paints");
let dashed = compile(frame_of(FrameItems::from_nodes(vec![stroked_node(
RECT_OWNER,
Geometry::Ellipse(rect),
AffineTransform::identity(),
stroke,
)])))
.expect("admitted dashed ellipse frame");
let item = &dashed.drawlist.items[1];
assert_eq!(item.world, to_affine(AffineTransform::identity()));
match &item.kind {
ItemKind::AbsoluteDashedOvalStroke {
x,
y,
w,
h,
stroke,
dash_phase,
} => {
assert_eq!((*x, *y, *w, *h), (16.0, 8.0, 32.0, 24.0));
assert_eq!(stroke.dash_array.as_deref(), Some(&[6.0, 3.0][..]));
assert_eq!(*dash_phase, StrokeDashPhase::ZERO);
match stroke.paints.as_slice() {
[Paint::LinearGradient(gradient)] => {
assert_eq!(gradient.transform, Affine::IDENTITY)
}
other => panic!("dashed ellipse lost its gradient material: {other:?}"),
}
}
other => panic!("dashed ellipse lost its absolute oval route: {other:?}"),
}

let solid = checked_stroke(
8.0,
rframe::StrokeCap::Round,
rframe::StrokeJoin::Miter,
4.0,
None,
);
let solid = compile(frame_of(FrameItems::from_nodes(vec![stroked_node(
RECT_OWNER,
Geometry::Ellipse(rect),
AffineTransform::identity(),
solid,
)])))
.expect("admitted solid ellipse frame");
assert!(matches!(
solid.drawlist.items[1].kind,
ItemKind::OvalStroke { .. }
));
}

#[test]
fn absolute_dashed_ellipse_coordinates_participate_in_damage_and_raster_identity() {
let scene = |x| {
let node = dashed_node(
RECT_OWNER,
Geometry::Ellipse(Rectangle::from_xywh(x, 8.0, 32.0, 24.0)),
vec![6.0, 3.0],
);
compile(frame_of(FrameItems::from_nodes(vec![node])))
.expect("admitted dashed ellipse frame")
};
let before = scene(16.0);
let after = scene(17.0);

assert_ne!(before.drawlist, after.drawlist);
assert!(!before.drawlist.raster_eq(&after.drawlist));
assert_eq!(diff_frame(&before, &after).changed, vec![RECT_OWNER]);
assert!(diff_frame(&before, &before).is_empty());
}

/// The path arm shares the same solid-only normalization but is a distinct
/// painter route. Pin its closed-contour zero-length dash independently.
#[test]
Expand Down
Loading
Loading