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 .github/workflows/consolidation-gates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ jobs:
needs: scope
if: needs.scope.outputs.engine == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 30
timeout-minutes: 45
steps:
- name: Checkout head revision
uses: actions/checkout@v4
Expand Down
22 changes: 22 additions & 0 deletions crates/n0/src/drawlist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,22 @@ pub(crate) enum ResolvedFilterConvolveEdgeMode {
None,
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum ResolvedFilterLightSource {
Distant {
direction: [f32; 3],
},
Point {
location: [f32; 3],
},
Spot {
location: [f32; 3],
target: [f32; 3],
falloff_exponent: f32,
cutoff_angle: f32,
},
}

/// The private filter-operation vocabulary admitted by the painter.
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum ResolvedFilterPrimitive {
Expand Down Expand Up @@ -189,6 +205,12 @@ pub(crate) enum ResolvedFilterPrimitive {
edge_mode: ResolvedFilterConvolveEdgeMode,
preserve_alpha: bool,
},
DiffuseLighting {
surface_scale: f32,
diffuse_constant: f32,
color: n0_model::model::Color,
light: ResolvedFilterLightSource,
},
Merge,
}

Expand Down
35 changes: 32 additions & 3 deletions crates/n0/src/glyphless.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ use n0_model::model::{
use n0_model::path::ResolvedPathArtifact;
use rframe::{
ClipPath, FilterBlend, FilterColorSpace, FilterComposite, FilterConvolveEdgeMode,
FilterDisplacementChannel, FilterInput, FilterMorphology, FilterPrimitive,
FilterDisplacementChannel, FilterInput, FilterLightSource, FilterMorphology, FilterPrimitive,
FilterTurbulenceKind, Frame, FrameItem, Geometry, MaskMode, PaintStack, ScopeEffect, VisualRef,
};

Expand All @@ -34,8 +34,8 @@ use crate::drawlist::{
ResolvedClipGeometryKind, ResolvedClipLayer, ResolvedClipPath, ResolvedFilter,
ResolvedFilterBlend, ResolvedFilterColorSpace, ResolvedFilterComposite,
ResolvedFilterConvolveEdgeMode, ResolvedFilterDisplacementChannel, ResolvedFilterInput,
ResolvedFilterMorphology, ResolvedFilterNode, ResolvedFilterPrimitive,
ResolvedFilterTurbulenceKind, ResolvedMaskMode, StrokeDashPhase,
ResolvedFilterLightSource, ResolvedFilterMorphology, ResolvedFilterNode,
ResolvedFilterPrimitive, ResolvedFilterTurbulenceKind, ResolvedMaskMode, StrokeDashPhase,
};
use crate::frame::FrameExecutionError;
use crate::paint::PaintCtx;
Expand Down Expand Up @@ -1054,6 +1054,35 @@ fn compile_filter(filter: &rframe::Filter) -> ResolvedFilter {
},
preserve_alpha,
},
FilterPrimitive::DiffuseLighting {
surface_scale,
diffuse_constant,
color,
light,
} => ResolvedFilterPrimitive::DiffuseLighting {
surface_scale,
diffuse_constant,
color: compile_color(color),
light: match light {
FilterLightSource::Distant { direction } => {
ResolvedFilterLightSource::Distant { direction }
}
FilterLightSource::Point { location } => {
ResolvedFilterLightSource::Point { location }
}
FilterLightSource::Spot {
location,
target,
falloff_exponent,
cutoff_angle,
} => ResolvedFilterLightSource::Spot {
location,
target,
falloff_exponent,
cutoff_angle,
},
},
},
FilterPrimitive::Merge => ResolvedFilterPrimitive::Merge,
},
})
Expand Down
150 changes: 145 additions & 5 deletions crates/n0/src/paint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,17 @@ use skia_safe::{
image::CachingHint, path_effect::PathEffect, shaders, stroke_rec::InitStyle, Blender, Canvas,
ClipOp, Color, Color4f, ColorChannel, ColorMatrix, ColorSpace, CubicResampler, Data, Font,
ISize, Image, ImageFilter, ImageInfo, Matrix, OpBuilder, Paint, PaintCap, PaintJoin,
PaintStyle, Path, PathBuilder, PathDirection, PathFillType, PathOp, Point, RRect, Rect,
PaintStyle, Path, PathBuilder, PathDirection, PathFillType, PathOp, Point, Point3, RRect, Rect,
SamplingOptions, Shader, StrokeRec,
};

use crate::drawlist::{
DrawList, ItemKind, PostPaintOpacity, ResolvedClipGeometry, ResolvedClipGeometryKind,
ResolvedClipLayer, ResolvedClipPath, ResolvedFilter, ResolvedFilterBlend,
ResolvedFilterColorSpace, ResolvedFilterComposite, ResolvedFilterConvolveEdgeMode,
ResolvedFilterDisplacementChannel, ResolvedFilterInput, ResolvedFilterMorphology,
ResolvedFilterPrimitive, ResolvedFilterTurbulenceKind, ResolvedMaskMode, StrokeDashPhase,
ResolvedFilterDisplacementChannel, ResolvedFilterInput, ResolvedFilterLightSource,
ResolvedFilterMorphology, ResolvedFilterPrimitive, ResolvedFilterTurbulenceKind,
ResolvedMaskMode, StrokeDashPhase,
};

/// The gradient family whose local matrix could not be represented by the
Expand Down Expand Up @@ -2339,6 +2340,34 @@ fn sk_convolve_tile_mode(mode: ResolvedFilterConvolveEdgeMode) -> skia_safe::Til
}
}

fn sk_lighting_color(color: n0_model::model::Color, space: ResolvedFilterColorSpace) -> Color {
let argb = color.argb();
let channel = |shift| ((argb >> shift) & 0xff_u32) as u8;
let to_linear_byte = |component: u8| {
let component = f32::from(component) / 255.0;
let linear = if component <= 0.04045 {
component / 12.92
} else {
((component + 0.055) / 1.055).powf(2.4)
};
(linear * 255.0).round() as u8
};
let (r, g, b) = (channel(16), channel(8), channel(0));
match space {
ResolvedFilterColorSpace::Srgb => Color::from_argb(u8::MAX, r, g, b),
ResolvedFilterColorSpace::LinearRgb => Color::from_argb(
u8::MAX,
to_linear_byte(r),
to_linear_byte(g),
to_linear_byte(b),
),
}
}

fn sk_point3(point: [f32; 3]) -> Point3 {
Point3::new(point[0], point[1], point[2])
}

/// Build one checked private filter graph and its final-composition policy.
fn build_filter(filter: &ResolvedFilter) -> Result<BuiltFilter, String> {
let explicit_transparent_source = if filter.source_is_transparent {
Expand Down Expand Up @@ -2419,6 +2448,7 @@ fn build_filter(filter: &ResolvedFilter) -> Result<BuiltFilter, String> {
ColorRestore::Floating
})
}
ResolvedFilterPrimitive::DiffuseLighting { .. } => Some(ColorRestore::Default),
ResolvedFilterPrimitive::SolidColor { .. } => None,
_ => inherited_color_restore,
};
Expand Down Expand Up @@ -2899,6 +2929,67 @@ fn build_filter(filter: &ResolvedFilter) -> Result<BuiltFilter, String> {
|| node.color_space == ResolvedFilterColorSpace::Srgb,
)
}
ResolvedFilterPrimitive::DiffuseLighting {
surface_scale,
diffuse_constant,
color,
light,
} => {
let input = inputs
.pop()
.expect("diffuse lighting has one checked input");
procedural_provenance = false;
procedural_unorm8_blend = false;
let color = sk_lighting_color(color, node.color_space);
let filter = match light {
ResolvedFilterLightSource::Distant { direction } => {
skia_safe::image_filters::distant_lit_diffuse(
sk_point3(direction),
color,
surface_scale,
diffuse_constant,
input.image_filter,
crop,
)
}
ResolvedFilterLightSource::Point { location } => {
skia_safe::image_filters::point_lit_diffuse(
sk_point3(location),
color,
surface_scale,
diffuse_constant,
input.image_filter,
crop,
)
}
ResolvedFilterLightSource::Spot {
location,
target,
falloff_exponent,
cutoff_angle,
} => skia_safe::image_filters::spot_lit_diffuse(
sk_point3(location),
sk_point3(target),
falloff_exponent,
cutoff_angle,
color,
surface_scale,
diffuse_constant,
input.image_filter,
crop,
),
}
.ok_or_else(|| {
"the backend could not construct a diffuse-lighting operation".to_string()
})?;
(
Some(filter),
node.color_space,
input.source_dependent,
input.requires_exact_restore
|| node.color_space == ResolvedFilterColorSpace::Srgb,
)
}
ResolvedFilterPrimitive::Merge => {
let mut inputs = inputs.into_iter();
let image_filter = if let Some(first) = inputs.next() {
Expand Down Expand Up @@ -3000,10 +3091,11 @@ mod filter_policy_tests {
use std::sync::Arc;

use n0_model::math::RectF;
use n0_model::model::Color32F;
use n0_model::model::{Color as ModelColor, Color32F};

use crate::drawlist::{
ResolvedFilterConvolveEdgeMode, ResolvedFilterMorphology, ResolvedFilterNode,
ResolvedFilterConvolveEdgeMode, ResolvedFilterLightSource, ResolvedFilterMorphology,
ResolvedFilterNode,
};

use super::{
Expand Down Expand Up @@ -3277,6 +3369,54 @@ mod filter_policy_tests {
.expect("preserved alpha remains a checked native operation");
assert!(alpha_preserving.source_preflatten);
}

#[test]
fn every_checked_diffuse_light_kind_builds_with_its_color_space_policy() {
let lights = [
ResolvedFilterLightSource::Distant {
direction: [0.5, -0.5, std::f32::consts::FRAC_1_SQRT_2],
},
ResolvedFilterLightSource::Point {
location: [4.0, 8.0, 12.0],
},
ResolvedFilterLightSource::Spot {
location: [2.0, 3.0, 8.0],
target: [7.0, 6.0, 0.0],
falloff_exponent: 8.0,
cutoff_angle: 35.0,
},
];
for color_space in [
ResolvedFilterColorSpace::Srgb,
ResolvedFilterColorSpace::LinearRgb,
] {
for light in lights {
let filter = ResolvedFilter {
region: REGION,
nodes: Arc::from([ResolvedFilterNode {
inputs: Arc::from([ResolvedFilterInput::SourceAlpha]),
region: REGION,
color_space,
primitive: ResolvedFilterPrimitive::DiffuseLighting {
surface_scale: -2.0,
diffuse_constant: 0.75,
color: ModelColor(0xffff_b347),
light,
},
}]),
may_paint_transparent_input: true,
source_is_transparent: false,
};
let built = build_filter(&filter).expect("checked native diffuse light builds");
assert!(!built.source_preflatten);
assert_eq!(
built.restore_blender.is_some(),
color_space == ResolvedFilterColorSpace::Srgb,
"sRGB lighting uses the architecture-neutral outer restore; gamma conversion ends it"
);
}
}
}
}

/// Product-build preflight for a resolved image-filter graph. Replay repeats
Expand Down
66 changes: 53 additions & 13 deletions crates/n0_cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,8 +259,11 @@ cargo run -p n0_cli --bin n0 -- \
authored result names. Its current operations are `feGaussianBlur`, integer
`feOffset`, zero-input `feFlood`, all seven `feComposite` operators,
all sixteen two-input `feBlend` modes, ordered `feMerge`/`feMergeNode`,
native one-input `feDropShadow`, and one-input `feColorMatrix` and
`feComponentTransfer`, plus one-input `feMorphology`. Inputs
native one-input `feDropShadow`, one-input `feColorMatrix`,
`feComponentTransfer`, `feMorphology`, `feConvolveMatrix`, and
`feDiffuseLighting` with one direct `feDistantLight`, `fePointLight`, or
`feSpotLight` child, plus zero-input `feTurbulence` and two-input
`feDisplacementMap`. Inputs
resolve to `SourceGraphic`, `SourceAlpha`, the previous result, or an earlier
named result before the frame; unknown values follow Chromium's measured
first/previous fallback.
Expand Down Expand Up @@ -430,6 +433,36 @@ cargo run -p n0_cli --bin n0 -- \
accepted kernel-size strategy through 256 stay admitted. Representative
fallback branches are celled; the wider invalid-spelling matrix is measured,
not all separately celled.
Diffuse lighting consumes one input's alpha as a height field and carries one
already-resolved distant, point, or spot light. The first recognized direct
light child wins; non-light children are ignored, nested lights do not
participate, and no light produces transparent black. The operation's own
output is opaque across its primitive subregion, including opaque black at
zero diffuse constant. Missing input follows the established first/previous
graph fallback; SourceGraphic and SourceAlpha therefore give the same
illumination for the same source coverage.
`surfaceScale` and `diffuseConstant` carry signed SVG numbers with initial
one; an exactly empty attribute becomes zero, malformed nonempty text uses
the initial, surface height keeps its sign, and a negative diffuse constant
clamps to zero. Distant angles are signed and periodic. Point and spot
coordinates default independently to zero. Under object-box primitive units,
their x/y coordinates use the target axes and z uses the normalized diagonal.
Spot exponent defaults to one and clamps to 1–128. A missing, zero, or
out-of-range cone angle uses the measured 90-degree behavior; an in-range
negative angle equals its positive magnitude.
Direct `lighting-color` carries initial white, admitted sRGB forms,
`currentColor`, reset/invalid fallback, non-inheritance, and ignored authored
alpha. The light channels adapt to the selected filter color space; missing
interpolation is linearRGB and explicit sRGB differs. CSS lighting color,
explicit inheritance, `var()`, and wider color functions refuse by stable
name. General affine target mappings and diffuse output used as the
foreground of `feComposite` `in`/`atop` against a source-derived second input
have two further precision patrols. Axis maps, reflection, exact quarter
turns, other composite operators, blend/merge, neighboring one-input spatial
operations, regions, `<use>`, `viewBox`, stroke and gradient alpha, and target
opacity/clip/mask are Chromium-baked exact. Chromium ignores sampled valid
and invalid `kernelUnitLength` spellings; two cells carry the diffuse drop,
while that shared row and `feSpecularLighting` remain open.
Turbulence carries both procedural formulas: the case-sensitive values
`turbulence` and `fractalNoise`, one/two-axis non-negative `baseFrequency`,
integer `numOctaves` capped at nine, signed `seed`, and the case-sensitive
Expand Down Expand Up @@ -498,27 +531,34 @@ cargo run -p n0_cli --bin n0 -- \
Initializing Skia before drawlist replay selects the fused AVX2 path on x86.
The 700-cell baseline is byte-exact on ARM and hosted x86 without a
tolerance. The forty-one-cell convolution rung keeps the complete 741-cell
gate byte-exact on ARM and hosted x86 without a new tolerance. All three
hundred eighty Chromium-baked filter cells are exact on both hosts.
gate byte-exact on ARM and hosted x86 without a new tolerance. The
seventy-one-cell diffuse-lighting rung keeps the complete 812-cell gate
byte-exact without a new tolerance. All four hundred fifty-one
Chromium-baked filter cells are exact.
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, and 41 convolution-rung cells. The complete corpus
contains 741 Chromium-baked cells plus 10 sampled frames, with 146 named
91 turbulence/displacement, 41 convolution-rung, and 71 diffuse-lighting
cells. The complete corpus contains 812 Chromium-baked cells plus 10 sampled
frames, with 152 named
refusal rows. `feFlood`, `feComposite`,
`feMerge`, `feMergeNode`, `feDropShadow`, `feColorMatrix`,
`feComponentTransfer`, `feBlend`,
`feMorphology`, `feConvolveMatrix`, `feTurbulence`, `feDisplacementMap`,
`feComponentTransfer`, `feBlend`, `feMorphology`, `feConvolveMatrix`,
`feDiffuseLighting`, `feDistantLight`, `fePointLight`, `feSpotLight`,
`feTurbulence`, `feDisplacementMap`,
`feFuncR`, `feFuncG`, `feFuncB`, `feFuncA`, `k1`–`k4`, `amplitude`,
`exponent`, `intercept`,
`slope`, `tableValues`, blend-only `mode`, `baseFrequency`, `numOctaves`,
`seed`, `stitchTiles`, displacement `scale`, `xChannelSelector`, and
`yChannelSelector`, `bias`, `divisor`, `edgeMode`, `kernelMatrix`,
convolution `order`, `preserveAlpha`, `targetX`, and `targetY` close;
`feOffset`, `feGaussianBlur`, `<filter>`,
convolution `order`, `preserveAlpha`, `targetX`, `targetY`, `azimuth`,
`diffuseConstant`, `elevation`, `limitingConeAngle`, `pointsAtX`,
`pointsAtY`, `pointsAtZ`, and `surfaceScale` close;
`feOffset`, `feGaussianBlur`, `feSpecularLighting`, `<filter>`,
`filter`, `color-interpolation-filters`, `in`, `in2`, `operator`, `result`,
`radius`, `kernelUnitLength`, `dx`, `dy`, `stdDeviation`, `flood-color`, and
`flood-opacity` remain open for the named precision, applicability, resource,
cascade, or value remainder.
`radius`, `kernelUnitLength`, `lighting-color`, `specularExponent`, `x`, `y`,
`z`, `dx`, `dy`, `stdDeviation`, `flood-color`, and `flood-opacity` remain
open for the named precision, applicability, resource, cascade, or value
remainder.
A stroke is centred, its width is a cascaded length in either spelling —
numbers, absolute units, `em`/`rem` against an authored or default
font-size, percentages against the normalized diagonal, and pure-length
Expand Down
Loading
Loading