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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
93 changes: 57 additions & 36 deletions crates/cg/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2460,49 +2460,69 @@ impl Default for LinearGradientPaint {
}
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
/// One intrinsic circle in gradient-local coordinates, before paint placement.
///
/// The center is a direct `(x, y)` pair, not an alignment point. Both components
/// must be finite; the radius must be finite and nonnegative. Centers are not
/// confined to a unit box. Validation belongs to the admitting boundary.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct RadialGradientCircle {
pub center: (f32, f32),
pub radius: f32,
}

/// Ordered ramp-boundary circles: offset zero at `start`, offset one at `end`.
///
/// Either radius may be zero, the start radius may exceed the end radius, and
/// equal circles are representable. None of these cases permits swapping or
/// clamping the circles. Representation does not guarantee backend support:
/// a consumer must preserve the geometry or explicitly refuse it.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct RadialGradientGeometry {
pub start: RadialGradientCircle,
pub end: RadialGradientCircle,
}

#[derive(Clone, PartialEq, Serialize, Deserialize)]
pub struct RadialGradientPaint {
pub active: bool,
/// # Radial Gradient Transform Model
///
/// ## Coordinate Space
/// The radial gradient is defined in **unit gradient space**:
/// - Center: `(0.5, 0.5)`
/// - Radius: `0.5`
///
/// This forms a normalized circle inside a `[0.0, 1.0] x [0.0, 1.0]` box.
/// All geometry is defined relative to this unit space.
///
/// ## Scaling to Object Space
/// The gradient is mapped to the target rectangle by applying a scale matrix derived from its size:
///
/// ```text
/// local_matrix = scale(width, height) × user_transform
/// ```
///
/// - `scale(width, height)` transforms the unit circle to match the target rectangle,
/// allowing the gradient to become elliptical if `width ≠ height`.
/// - `user_transform` is an additional affine matrix defined in gradient space (centered at 0.5, 0.5).
///
/// ## Rendering Behavior
/// When passed to Skia, the shader uses:
/// - `center = (0.5, 0.5)`
/// - `radius = 0.5`
///
/// These are interpreted in **local gradient space**, and the `local_matrix` maps device coordinates
/// back into that space.
///
/// ## Summary
/// - The gradient definition is resolution-independent.
/// - `width` and `height` determine how unit space is scaled — they do **not** directly affect center or radius.
/// - All transforms (e.g. rotation, skew) should be encoded in the `user_transform`, not baked into radius or center.
/// Maps gradient-local coordinates to the target box's normalized space.
/// Placement is `scale(width, height) × transform`; intrinsic circle values
/// are not converted through center-based alignment coordinates.
pub transform: AffineTransform,
/// Explicit ordered circles, or the original centered radial when absent:
/// start `(0.5, 0.5), r=0`, end `(0.5, 0.5), r=0.5`.
///
/// Absence preserves the original rendering path and serialized field set.
/// Present geometry survives copying and serialization without normalization,
/// including a present pair numerically equal to those implicit circles.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub geometry: Option<RadialGradientGeometry>,
pub stops: Vec<GradientStop>,
pub opacity: f32,
pub blend_mode: BlendMode,
pub tile_mode: TileMode,
}

impl std::fmt::Debug for RadialGradientPaint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut debug = f.debug_struct("RadialGradientPaint");
debug
.field("active", &self.active)
.field("transform", &self.transform);
// Keep diagnostics of old paint values stable without hiding new facts.
if let Some(geometry) = &self.geometry {
debug.field("geometry", geometry);
}
debug
.field("stops", &self.stops)
.field("opacity", &self.opacity)
.field("blend_mode", &self.blend_mode)
.field("tile_mode", &self.tile_mode)
.finish()
}
}

impl RadialGradientPaint {
pub fn from_stops(stops: Vec<GradientStop>) -> Self {
Self {
Expand All @@ -2524,6 +2544,7 @@ impl Default for RadialGradientPaint {
Self {
active: true,
transform: AffineTransform::default(),
geometry: None,
stops: Vec::new(),
opacity: 1.0,
blend_mode: BlendMode::default(),
Expand All @@ -2539,8 +2560,8 @@ pub struct DiamondGradientPaint {
///
/// Figma's Diamond Gradient is equivalent to a radial gradient evaluated
/// using the Manhattan distance metric. The gradient is defined in the same
/// unit space as [`RadialGradientPaint`]: center at `(0.5, 0.5)` with a
/// nominal radius of `0.5`.
/// unit space as the implicit [`RadialGradientPaint`] default: center at
/// `(0.5, 0.5)` with a nominal radius of `0.5`.
///
/// Scaling to object space follows the same rule:
///
Expand Down
114 changes: 114 additions & 0 deletions crates/cg/tests/radial_geometry.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
//! Serialization contract for the ordered radial-circle value, without a backend.

use cg::{RadialGradientCircle, RadialGradientGeometry, RadialGradientPaint};
use serde_json::json;

#[test]
fn absent_geometry_preserves_the_old_json_field_set_and_input() {
let old = json!({
"active": true,
"transform": {"matrix": [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]},
"stops": [],
"opacity": 1.0,
"blend_mode": "normal",
"tile_mode": "clamp"
});
let paint: RadialGradientPaint = serde_json::from_value(old.clone()).unwrap();
assert_eq!(paint, RadialGradientPaint::default());
assert_eq!(paint.geometry, None);
assert_eq!(serde_json::to_value(&paint).unwrap(), old);
assert_eq!(
serde_json::to_string(&paint).unwrap(),
r#"{"active":true,"transform":{"matrix":[[1.0,0.0,0.0],[0.0,1.0,0.0]]},"stops":[],"opacity":1.0,"blend_mode":"normal","tile_mode":"clamp"}"#
);
}

#[test]
fn debug_preserves_absent_values_but_never_hides_present_geometry() {
let mut paint = RadialGradientPaint::default();
assert_eq!(format!("{paint:?}"), "RadialGradientPaint { active: true, transform: AffineTransform { matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] }, stops: [], opacity: 1.0, blend_mode: Normal, tile_mode: Clamp }");
paint.geometry = Some(RadialGradientGeometry {
start: RadialGradientCircle {
center: (-2.0, 1.0),
radius: 0.75,
},
end: RadialGradientCircle {
center: (0.5, 0.5),
radius: 0.0,
},
});
let debug = format!("{paint:?}");
assert!(debug.contains("geometry: RadialGradientGeometry { start: RadialGradientCircle { center: (-2.0, 1.0), radius: 0.75 }, end: RadialGradientCircle { center: (0.5, 0.5), radius: 0.0 } }"));
}

fn circle_bits(circle: RadialGradientCircle) -> [u32; 3] {
[
circle.center.0.to_bits(),
circle.center.1.to_bits(),
circle.radius.to_bits(),
]
}

#[test]
fn present_geometry_round_trips_without_alignment_conversion_or_circle_reordering() {
// A subnormal, signed zero, and a radius larger than the point-sized end
// discriminate arithmetic normalization and inferred circle order.
let paint = RadialGradientPaint {
geometry: Some(RadialGradientGeometry {
start: RadialGradientCircle {
center: (f32::from_bits(1), -0.0),
radius: 1.75,
},
end: RadialGradientCircle {
center: (3.5, -2.25),
radius: 0.0,
},
}),
..Default::default()
};
let encoded = serde_json::to_string(&paint).unwrap();
let decoded: RadialGradientPaint = serde_json::from_str(&encoded).unwrap();
let geometry = decoded.geometry.unwrap();
assert_eq!(circle_bits(geometry.start), [1, 0x8000_0000, 0x3fe0_0000]);
assert_eq!(circle_bits(geometry.end), [0x4060_0000, 0xc010_0000, 0]);
assert_eq!(decoded, paint);
assert_eq!(
serde_json::to_value(&decoded).unwrap()["geometry"]["end"],
json!({"center": [3.5, -2.25], "radius": 0.0})
);
}

#[test]
fn explicit_implicit_circle_values_remain_present() {
let geometry = RadialGradientGeometry {
start: RadialGradientCircle {
center: (0.5, 0.5),
radius: 0.0,
},
end: RadialGradientCircle {
center: (0.5, 0.5),
radius: 0.5,
},
};
let paint = RadialGradientPaint {
geometry: Some(geometry),
..Default::default()
};
let decoded: RadialGradientPaint =
serde_json::from_value(serde_json::to_value(paint).unwrap()).unwrap();
assert_eq!(decoded.geometry, Some(geometry));
}

#[test]
fn a_present_geometry_requires_both_complete_circles() {
for incomplete in [
json!({"start": {"center": [0.5, 0.5], "radius": 0.0}}),
json!({"end": {"center": [0.5, 0.5], "radius": 0.5}}),
json!({"start": {"center": [0.5, 0.5]}, "end": {"center": [0.5, 0.5], "radius": 0.5}}),
json!({"start": {"center": [0.5, 0.5], "radius": 0.0}, "end": {"radius": 0.5}}),
] {
let mut json = serde_json::to_value(RadialGradientPaint::default()).unwrap();
json["geometry"] = incomplete;
assert!(serde_json::from_value::<RadialGradientPaint>(json).is_err());
}
}
1 change: 1 addition & 0 deletions crates/grida/examples/fixture_helpers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ pub(crate) fn linear_gradient() -> Paint {

pub(crate) fn radial_gradient() -> Paint {
Paint::RadialGradient(RadialGradientPaint {
geometry: None,
active: true,
transform: AffineTransform::default(),
stops: vec![
Expand Down
2 changes: 2 additions & 0 deletions crates/grida/examples/fixtures/cover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ pub(crate) fn build() -> Scene {
height: 1100.0,
},
fills: Paints::new(vec![Paint::RadialGradient(RadialGradientPaint {
geometry: None,
active: true,
transform: AffineTransform::default(),
stops: vec![
Expand Down Expand Up @@ -190,6 +191,7 @@ pub(crate) fn build() -> Scene {
height: 1000.0,
},
fills: Paints::new(vec![Paint::RadialGradient(RadialGradientPaint {
geometry: None,
active: true,
transform: AffineTransform::default(),
stops: vec![
Expand Down
1 change: 1 addition & 0 deletions crates/grida/examples/fixtures/l0_paints_stack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ pub(crate) fn build() -> Scene {
}),
// [2] radial gradient yellow center→transparent
Paint::RadialGradient(RadialGradientPaint {
geometry: None,
active: true,
transform: AffineTransform::default(),
stops: vec![
Expand Down
1 change: 1 addition & 0 deletions crates/grida/src/import/svg/paint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ fn svg_radial_gradient_to_paint(
Paint::RadialGradient(RadialGradientPaint {
active: true,
transform: gradient_transform.compose(&alignment),
geometry: None,
stops: radial.stops.clone(),
opacity,
blend_mode: BlendMode::Normal,
Expand Down
15 changes: 15 additions & 0 deletions crates/grida/src/io/io_grida_fbs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,7 @@ fn decode_paint_item(item: &fbs::PaintStackItem<'_>) -> Option<Paint> {
Some(Paint::RadialGradient(RadialGradientPaint {
active: rgp.active(),
transform,
geometry: None,
stops,
opacity: rgp.opacity(),
blend_mode: decode_blend_mode(rgp.blend_mode()),
Expand Down Expand Up @@ -2355,6 +2356,11 @@ use crate::node::schema::NodeId;
/// - `id_map`: maps internal `NodeId` → string IDs.
///
/// Returns the encoded bytes (including the `"GRID"` file identifier).
///
/// # Panics
///
/// Panics if a radial paint carries explicit ordered circles: the frozen
/// format cannot encode that geometry. It is never silently omitted.
pub fn encode(
scene: &Scene,
scene_id: &str,
Expand Down Expand Up @@ -2424,6 +2430,11 @@ pub fn encode(
/// Each entry is `(scene_id, scene, id_map, position_map)`.
/// All scenes share the same flat `nodes` vector; each scene's nodes
/// are prefixed with a scene-type NodeSlot that references `scene_id`.
///
/// # Panics
///
/// Like [`encode`], refuses explicit radial circles by a named panic because
/// the frozen format has no representation for them.
#[allow(clippy::type_complexity)]
pub fn encode_multi(
entries: &[(
Expand Down Expand Up @@ -2910,6 +2921,10 @@ fn encode_paint_raw<'a, A: flatbuffers::Allocator + 'a>(
Some((fbs::Paint::LinearGradientPaint, lgp.as_union_value()))
}
Paint::RadialGradient(rg) => {
assert!(
rg.geometry.is_none(),
"the frozen .grida format cannot encode explicit radial gradient circles"
);
let stops = encode_gradient_stops(fbb, &rg.stops);
let transform = encode_affine_to_cg_transform(&rg.transform);
let rgp = fbs::RadialGradientPaint::create(
Expand Down
Loading
Loading