Skip to main content

pax_runtime_api/
drawing.rs

1//! Vector drawing primitives: paths, fills, strokes, caps, and gradients.
2
3use super::*;
4
5pub mod path_smoothing;
6pub mod path_trim;
7pub mod stroke_utils;
8
9/// Describes a single element of a vector path, such as a line, a point, or curve segment.
10#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
11#[serde(crate = "crate::serde")]
12pub enum PathElement {
13    /// No-op path element.
14    #[default]
15    Empty,
16    /// Moves the current point to the provided coordinate.
17    Point(Size, Size),
18    /// Draws a straight line to the following `Point`.
19    Line,
20    /// Draws a quadratic Bézier segment with one control point, ending at the following `Point`.
21    Quadratic(Size, Size),
22    /// Draws a cubic Bézier segment with two control points, ending at the following `Point`.
23    Cubic(Size, Size, Size, Size),
24    /// Closes the current contour.
25    Close,
26}
27
28impl Interpolatable for PathElement {}
29impl HelperFunctions for PathElement {}
30
31/// Controls optional curve smoothing for path geometry before tessellation.
32///
33/// `PathSmoothing` is intended for authored or imported paths whose source data
34/// approximates curves with many short line segments, such as single-stroke SVG
35/// fonts. Existing paths keep their exact geometry by default.
36#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq, Hash)]
37#[serde(crate = "crate::serde")]
38pub enum PathSmoothing {
39    /// Preserve the authored path exactly.
40    #[default]
41    None,
42    /// Lightly smooth polyline runs while preserving sharp corners.
43    Light,
44    /// More aggressively smooth polyline runs for pen-like paths.
45    Strong,
46}
47
48impl Interpolatable for PathSmoothing {
49    fn interpolate(&self, other: &Self, t: f64) -> Self {
50        if t < 1.0 {
51            *self
52        } else {
53            *other
54        }
55    }
56}
57
58/// Controls how an open stroke terminates at the exposed endpoints of a path.
59///
60/// `StrokeCap` affects primitives such as `Line` and open `Path` subpaths.
61/// Closed geometry ignores cap style because it has no exposed endpoints.
62#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)]
63#[serde(crate = "crate::serde")]
64pub enum StrokeCap {
65    /// Ends exactly at the path endpoint without extending past it.
66    #[default]
67    Butt,
68    /// Adds a semicircular cap whose radius is half the stroke width.
69    Round,
70    /// Adds a square cap that extends half the stroke width past the endpoint.
71    Square,
72}
73
74impl Interpolatable for StrokeCap {
75    fn interpolate(&self, other: &Self, t: f64) -> Self {
76        if t < 1.0 {
77            self.clone()
78        } else {
79            other.clone()
80        }
81    }
82}
83
84/// Controls how stroke segments are joined at path vertices.
85#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)]
86#[serde(crate = "crate::serde")]
87pub enum StrokeJoin {
88    /// Extends outer edges to a point, subject to the renderer's miter limit.
89    #[default]
90    Miter,
91    /// Rounds the outside of each join.
92    Round,
93    /// Cuts joins off with a straight edge.
94    Bevel,
95}
96
97impl Interpolatable for StrokeJoin {
98    fn interpolate(&self, other: &Self, t: f64) -> Self {
99        if t < 1.0 {
100            self.clone()
101        } else {
102            other.clone()
103        }
104    }
105}
106
107/// One interior paint layer. Index zero in an element's fill stack is topmost.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109#[serde(crate = "crate::serde")]
110pub struct Fill {
111    /// Color or gradient sampled over the complete local geometry.
112    pub paint: Property<Paint>,
113    /// Light-reactive surface response, matte by default.
114    pub material: Property<Material>,
115    /// Opacity of this layer before element opacity is applied.
116    pub opacity: Property<Opacity>,
117    // Future blend_mode governs composition over lower layers.
118}
119
120/// One centered outline layer, drawn above every interior fill.
121/// Index zero in an element's stroke stack is topmost.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123#[serde(crate = "crate::serde")]
124pub struct Stroke {
125    /// Color or gradient sampled over the complete, untrimmed geometry.
126    pub paint: Property<Paint>,
127    /// Light-reactive surface response, matte by default.
128    pub material: Property<Material>,
129    /// Opacity of this layer before element opacity is applied.
130    pub opacity: Property<Opacity>,
131    /// Width in logical pixels. Percentages are unsupported; nonpositive widths draw nothing.
132    pub width: Property<Size>,
133    /// Style of exposed endpoints on open paths.
134    pub cap: Property<StrokeCap>,
135    /// Style of joins between adjacent segments.
136    pub join: Property<StrokeJoin>,
137    // Future blend_mode governs composition over lower layers.
138}
139
140impl Default for Fill {
141    fn default() -> Self {
142        Self {
143            paint: Default::default(),
144            material: Default::default(),
145            opacity: Default::default(),
146        }
147    }
148}
149
150/// Plain drawing values captured before entering a rendering backend.
151/// Backends never read live shared properties while recording a frame.
152#[derive(Clone, Debug, PartialEq)]
153pub struct ResolvedStroke {
154    pub paint: Paint,
155    pub material: ResolvedMaterial,
156    pub opacity: Opacity,
157    pub width: Size,
158    pub cap: StrokeCap,
159    pub join: StrokeJoin,
160}
161
162impl Stroke {
163    /// Resolves nested fields from the entered UI graph's settled projections.
164    pub fn resolve_in_graph(&self) -> ResolvedStroke {
165        ResolvedStroke {
166            paint: self.paint.local().get(),
167            material: self.material.local().get().resolve_in_graph(),
168            opacity: self.opacity.local().get(),
169            width: self.width.local().get(),
170            cap: self.cap.local().get(),
171            join: self.join.local().get(),
172        }
173    }
174
175    /// Captures the latest published fields without entering a UI graph.
176    /// Independently published fields are not a multi-property transaction.
177    pub fn snapshot(&self) -> ResolvedStroke {
178        ResolvedStroke {
179            paint: self.paint.get(),
180            material: self.material.get().snapshot(),
181            opacity: self.opacity.get(),
182            width: self.width.get(),
183            cap: self.cap.get(),
184            join: self.join.get(),
185        }
186    }
187}
188
189impl Default for Stroke {
190    fn default() -> Self {
191        Self {
192            paint: Default::default(),
193            material: Default::default(),
194            opacity: Default::default(),
195            width: Property::new(Size::Pixels(1.into())),
196            cap: Default::default(),
197            join: Default::default(),
198        }
199    }
200}
201
202impl From<Paint> for Fill {
203    fn from(paint: Paint) -> Self {
204        Self {
205            paint: Property::new(paint),
206            ..Self::default()
207        }
208    }
209}
210impl From<Color> for Fill {
211    fn from(color: Color) -> Self {
212        Paint::Solid(color).into()
213    }
214}
215impl From<Paint> for Stroke {
216    fn from(paint: Paint) -> Self {
217        Self {
218            paint: Property::new(paint),
219            ..Self::default()
220        }
221    }
222}
223impl From<Color> for Stroke {
224    fn from(color: Color) -> Self {
225        Paint::Solid(color).into()
226    }
227}
228
229impl Stroke {
230    /// Resolves a finite, positive pixel width, or zero for an invisible stroke.
231    pub fn width_pixels(&self) -> f64 {
232        match self.width.get() {
233            Size::Pixels(value) if value.to_float().is_finite() => value.to_float().max(0.0),
234            _ => {
235                log::warn!("Stroke width must be finite logical pixels");
236                0.0
237            }
238        }
239    }
240}
241
242impl ResolvedStroke {
243    /// Resolves a finite, positive pixel width, or zero for an invisible stroke.
244    pub fn width_pixels(&self) -> f64 {
245        match self.width.clone() {
246            Size::Pixels(value) if value.to_float().is_finite() => value.to_float().max(0.0),
247            _ => {
248                log::warn!("Stroke width must be finite logical pixels");
249                0.0
250            }
251        }
252    }
253}
254
255macro_rules! appearance_layer_traits {
256    ($ty:ident, $($field:ident),+) => {
257        impl PartialEq for $ty {
258            fn eq(&self, other: &Self) -> bool { $(self.$field.get() == other.$field.get())&&+ }
259        }
260        impl Hash for $ty {
261            fn hash<H: Hasher>(&self, state: &mut H) { $(self.$field.get().hash(state);)+ }
262        }
263        impl Interpolatable for $ty {
264            fn interpolate(&self, other: &Self, t: f64) -> Self {
265                Self { $($field: Property::new(self.$field.get().interpolate(&other.$field.get(), t))),+ }
266            }
267        }
268    }
269}
270appearance_layer_traits!(Fill, paint, material, opacity);
271appearance_layer_traits!(Stroke, paint, material, opacity, width, cap, join);
272
273/// Describes where to open new windows or tabs when navigating to a URL from a `Link` node.
274pub enum NavigationTarget {
275    /// Navigate in the current window or tab.
276    Current,
277    /// Navigate in a new window or tab.
278    New,
279}
280
281/// Describes how to fill vector geometry.
282/// Solid fills interpolate their RGBA channels. Other pairs crossfade sampled
283/// paints in premultiplied RGBA, preserving each gradient's geometry and stops.
284#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
285#[serde(crate = "crate::serde")]
286pub enum Paint {
287    /// A single solid color.
288    Solid(Color),
289    /// A linear gradient.
290    LinearGradient(LinearGradient),
291    /// A radial gradient.
292    RadialGradient(RadialGradient),
293    /// A weighted paint mixture produced by interpolation. Weights are finite,
294    /// nonnegative, and sum to one. Use [`Paint::blend`] to normalize weights,
295    /// flatten nested mixtures, and combine repeated endpoints.
296    Blend(Vec<(Paint, f64)>),
297    /// A row-connected bicubic field with independently positioned colored anchors.
298    MeshGradient(MeshGradient),
299}
300
301impl From<Color> for Paint {
302    fn from(color: Color) -> Self {
303        Self::Solid(color)
304    }
305}
306
307impl Hash for Paint {
308    fn hash<H: Hasher>(&self, state: &mut H) {
309        match self {
310            Paint::Solid(color) => {
311                state.write_u8(0);
312                color.hash(state);
313            }
314            Paint::LinearGradient(linear) => {
315                state.write_u8(1);
316                linear.start.hash(state);
317                linear.end.hash(state);
318                linear.stops.hash(state);
319            }
320            Paint::RadialGradient(radial) => {
321                state.write_u8(2);
322                radial.start.hash(state);
323                radial.end.hash(state);
324                radial.radius.to_bits().hash(state);
325                radial.stops.hash(state);
326            }
327            Paint::Blend(terms) => {
328                state.write_u8(3);
329                terms.len().hash(state);
330                for (fill, weight) in terms {
331                    fill.hash(state);
332                    weight.to_bits().hash(state);
333                }
334            }
335            Paint::MeshGradient(mesh) => {
336                state.write_u8(4);
337                mesh.hash(state);
338            }
339        }
340    }
341}
342
343/// Logical scene depth for lighting calculations.
344///
345/// `Depth` is expressed in logical pixels. Unlike [`Size`], it is not anchored
346/// to a viewport or parent box, so percent and combined units are intentionally
347/// rejected during value coercion.
348#[derive(Debug, Copy, Clone, Serialize, Deserialize, Default, PartialEq, PartialOrd)]
349#[serde(crate = "crate::serde")]
350pub struct Depth(pub Numeric);
351
352impl Depth {
353    /// Returns the depth as a floating-point logical pixel value.
354    pub fn to_float(&self) -> f64 {
355        self.0.to_float()
356    }
357}
358
359impl From<Numeric> for Depth {
360    fn from(value: Numeric) -> Self {
361        Self(value)
362    }
363}
364
365impl From<f64> for Depth {
366    fn from(value: f64) -> Self {
367        Self(Numeric::F64(value))
368    }
369}
370
371impl Hash for Depth {
372    fn hash<H: Hasher>(&self, state: &mut H) {
373        self.0.hash(state);
374    }
375}
376
377impl Interpolatable for Depth {
378    fn interpolate(&self, other: &Self, t: f64) -> Self {
379        Self(self.0.interpolate(&other.0, t))
380    }
381}
382
383impl HelperFunctions for Depth {}
384
385/// A three-dimensional vector in logical scene space.
386#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq)]
387#[serde(crate = "crate::serde")]
388pub struct Vector3 {
389    /// X component.
390    pub x: f64,
391    /// Y component.
392    pub y: f64,
393    /// Z component.
394    pub z: f64,
395}
396
397impl Vector3 {
398    /// Constructs a 3D vector.
399    pub fn new(x: f64, y: f64, z: f64) -> Self {
400        Self { x, y, z }
401    }
402}
403
404impl Default for Vector3 {
405    fn default() -> Self {
406        Self {
407            x: 0.0,
408            y: 0.0,
409            z: 0.0,
410        }
411    }
412}
413
414impl Hash for Vector3 {
415    fn hash<H: Hasher>(&self, state: &mut H) {
416        self.x.to_bits().hash(state);
417        self.y.to_bits().hash(state);
418        self.z.to_bits().hash(state);
419    }
420}
421
422impl Interpolatable for Vector3 {
423    fn interpolate(&self, other: &Self, t: f64) -> Self {
424        Self {
425            x: self.x.interpolate(&other.x, t),
426            y: self.y.interpolate(&other.y, t),
427            z: self.z.interpolate(&other.z, t),
428        }
429    }
430}
431
432impl HelperFunctions for Vector3 {
433    fn register_all_functions() {
434        register_function(
435            "Vector3".to_string(),
436            "new".to_string(),
437            std::sync::Arc::new(|args| {
438                if args.len() != 3 {
439                    return Err("Expected 3 arguments for function Vector3::new".to_string());
440                }
441                let mut itr = args.into_iter();
442                let x = f64::try_coerce(itr.next().unwrap())?;
443                let y = f64::try_coerce(itr.next().unwrap())?;
444                let z = f64::try_coerce(itr.next().unwrap())?;
445                Ok(Vector3::new(x, y, z).to_pax_value())
446            }),
447        );
448    }
449}
450
451/// Shape of a light contribution in logical scene space.
452#[derive(Debug, Copy, Clone, Serialize, Deserialize, Default, PartialEq, Eq, Hash)]
453#[serde(crate = "crate::serde")]
454pub enum LightShape {
455    /// A positional light with radius-based attenuation.
456    #[default]
457    Point,
458    /// A light with direction but no position or attenuation.
459    Directional,
460}
461
462impl Interpolatable for LightShape {}
463impl HelperFunctions for LightShape {}
464
465/// Tunable response parameters for a light-reactive vector material.
466#[derive(Debug, Clone, Serialize, Deserialize)]
467#[serde(crate = "crate::serde")]
468pub struct MaterialParams {
469    /// Ambient contribution multiplier.
470    pub ambient: Property<f64>,
471    /// Diffuse contribution multiplier.
472    pub diffuse: Property<f64>,
473    /// Specular contribution multiplier.
474    pub specular: Property<f64>,
475    /// Surface roughness in the `[0.0, 1.0]` range.
476    pub roughness: Property<f64>,
477    /// Metallic response in the `[0.0, 1.0]` range.
478    pub metallic: Property<f64>,
479    /// Additive emissive color.
480    pub emissive: Property<Color>,
481    /// Additive emissive intensity.
482    pub emissive_intensity: Property<f64>,
483}
484
485impl Default for MaterialParams {
486    fn default() -> Self {
487        Self {
488            ambient: Property::new(1.0),
489            diffuse: Property::new(0.82),
490            specular: Property::new(0.08),
491            roughness: Property::new(0.78),
492            metallic: Property::new(0.0),
493            emissive: Property::new(Color::BLACK),
494            emissive_intensity: Property::new(0.0),
495        }
496    }
497}
498
499impl PartialEq for MaterialParams {
500    fn eq(&self, other: &Self) -> bool {
501        self.ambient.get() == other.ambient.get()
502            && self.diffuse.get() == other.diffuse.get()
503            && self.specular.get() == other.specular.get()
504            && self.roughness.get() == other.roughness.get()
505            && self.metallic.get() == other.metallic.get()
506            && self.emissive.get() == other.emissive.get()
507            && self.emissive_intensity.get() == other.emissive_intensity.get()
508    }
509}
510
511impl Hash for MaterialParams {
512    fn hash<H: Hasher>(&self, state: &mut H) {
513        self.ambient.get().to_bits().hash(state);
514        self.diffuse.get().to_bits().hash(state);
515        self.specular.get().to_bits().hash(state);
516        self.roughness.get().to_bits().hash(state);
517        self.metallic.get().to_bits().hash(state);
518        self.emissive.get().hash(state);
519        self.emissive_intensity.get().to_bits().hash(state);
520    }
521}
522
523impl Interpolatable for MaterialParams {
524    fn interpolate(&self, other: &Self, t: f64) -> Self {
525        Self {
526            ambient: Property::new(self.ambient.get().interpolate(&other.ambient.get(), t)),
527            diffuse: Property::new(self.diffuse.get().interpolate(&other.diffuse.get(), t)),
528            specular: Property::new(self.specular.get().interpolate(&other.specular.get(), t)),
529            roughness: Property::new(self.roughness.get().interpolate(&other.roughness.get(), t)),
530            metallic: Property::new(self.metallic.get().interpolate(&other.metallic.get(), t)),
531            emissive: Property::new(self.emissive.get().interpolate(&other.emissive.get(), t)),
532            emissive_intensity: Property::new(
533                self.emissive_intensity
534                    .get()
535                    .interpolate(&other.emissive_intensity.get(), t),
536            ),
537        }
538    }
539}
540
541impl HelperFunctions for MaterialParams {}
542
543/// Light-reactive surface response for vector primitives.
544///
545/// This is intentionally named `Material`; `texture` is reserved for future
546/// bitmap-backed texture maps and pattern data.
547#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
548#[serde(crate = "crate::serde")]
549pub enum Material {
550    /// Responds to scene lighting using parameterized material coefficients.
551    Lit(MaterialParams),
552    /// Ignores scene lighting and preserves legacy unlit rendering behavior.
553    Unlit,
554}
555
556/// Plain material coefficients for one settled drawing operation.
557#[derive(Clone, Debug, PartialEq)]
558pub struct ResolvedMaterialParams {
559    pub ambient: f64,
560    pub diffuse: f64,
561    pub specular: f64,
562    pub roughness: f64,
563    pub metallic: f64,
564    pub emissive: Color,
565    pub emissive_intensity: f64,
566}
567
568/// Material response with no live property handles.
569#[derive(Clone, Debug, PartialEq)]
570pub enum ResolvedMaterial {
571    Lit(ResolvedMaterialParams),
572    Unlit,
573}
574
575impl Material {
576    /// Resolves nested coefficients from the entered UI graph's projections.
577    pub fn resolve_in_graph(&self) -> ResolvedMaterial {
578        match self {
579            Self::Unlit => ResolvedMaterial::Unlit,
580            Self::Lit(p) => ResolvedMaterial::Lit(ResolvedMaterialParams {
581                ambient: p.ambient.local().get(),
582                diffuse: p.diffuse.local().get(),
583                specular: p.specular.local().get(),
584                roughness: p.roughness.local().get(),
585                metallic: p.metallic.local().get(),
586                emissive: p.emissive.local().get(),
587                emissive_intensity: p.emissive_intensity.local().get(),
588            }),
589        }
590    }
591
592    /// Captures published coefficients without evaluating the UI graph.
593    pub fn snapshot(&self) -> ResolvedMaterial {
594        match self {
595            Self::Unlit => ResolvedMaterial::Unlit,
596            Self::Lit(p) => ResolvedMaterial::Lit(ResolvedMaterialParams {
597                ambient: p.ambient.get(),
598                diffuse: p.diffuse.get(),
599                specular: p.specular.get(),
600                roughness: p.roughness.get(),
601                metallic: p.metallic.get(),
602                emissive: p.emissive.get(),
603                emissive_intensity: p.emissive_intensity.get(),
604            }),
605        }
606    }
607}
608
609impl Default for Material {
610    fn default() -> Self {
611        Self::matte()
612    }
613}
614
615impl Material {
616    /// A soft, low-specular material suitable as the default lit response.
617    pub fn matte() -> Self {
618        Self::Lit(MaterialParams::default())
619    }
620
621    /// A higher-specular material with lower roughness.
622    pub fn glossy(specular: f64) -> Self {
623        let mut params = MaterialParams::default();
624        params.specular = Property::new(specular.clamp(0.0, 1.0));
625        params.roughness = Property::new(0.28);
626        Self::Lit(params)
627    }
628
629    /// A metallic material response.
630    pub fn metallic(metallic: f64) -> Self {
631        let mut params = MaterialParams::default();
632        params.metallic = Property::new(metallic.clamp(0.0, 1.0));
633        params.diffuse = Property::new(0.42);
634        params.specular = Property::new(0.55);
635        params.roughness = Property::new(0.36);
636        Self::Lit(params)
637    }
638
639    /// An emissive material that adds color independent of lights.
640    pub fn emissive(color: Color, intensity: f64) -> Self {
641        let mut params = MaterialParams::default();
642        params.emissive = Property::new(color);
643        params.emissive_intensity = Property::new(intensity.max(0.0));
644        Self::Lit(params)
645    }
646
647    /// Creates a lit material from explicit coefficients.
648    pub fn custom(params: MaterialParams) -> Self {
649        Self::Lit(params)
650    }
651
652    /// A material that ignores authored lights.
653    pub fn unlit() -> Self {
654        Self::Unlit
655    }
656}
657
658impl Hash for Material {
659    fn hash<H: Hasher>(&self, state: &mut H) {
660        match self {
661            Material::Lit(params) => {
662                state.write_u8(0);
663                params.hash(state);
664            }
665            Material::Unlit => {
666                state.write_u8(1);
667            }
668        }
669    }
670}
671
672impl Interpolatable for Material {
673    fn interpolate(&self, other: &Self, t: f64) -> Self {
674        match (self, other) {
675            (Self::Lit(lhs), Self::Lit(rhs)) => Self::Lit(lhs.interpolate(rhs, t)),
676            _ if t < 1.0 => self.clone(),
677            _ => other.clone(),
678        }
679    }
680}
681
682impl HelperFunctions for Material {
683    fn register_all_functions() {
684        register_function(
685            "Material".to_string(),
686            "matte".to_string(),
687            std::sync::Arc::new(|args| {
688                if !args.is_empty() {
689                    return Err("Expected 0 arguments for function Material::matte".to_string());
690                }
691                Ok(Material::matte().to_pax_value())
692            }),
693        );
694        register_function(
695            "Material".to_string(),
696            "glossy".to_string(),
697            std::sync::Arc::new(|args| {
698                if args.len() != 1 {
699                    return Err("Expected 1 argument for function Material::glossy".to_string());
700                }
701                let specular = f64::try_coerce(args.into_iter().next().unwrap())?;
702                Ok(Material::glossy(specular).to_pax_value())
703            }),
704        );
705        register_function(
706            "Material".to_string(),
707            "metallic".to_string(),
708            std::sync::Arc::new(|args| {
709                if args.len() != 1 {
710                    return Err("Expected 1 argument for function Material::metallic".to_string());
711                }
712                let metallic = f64::try_coerce(args.into_iter().next().unwrap())?;
713                Ok(Material::metallic(metallic).to_pax_value())
714            }),
715        );
716        register_function(
717            "Material".to_string(),
718            "emissive".to_string(),
719            std::sync::Arc::new(|args| {
720                if args.len() != 2 {
721                    return Err("Expected 2 arguments for function Material::emissive".to_string());
722                }
723                let mut itr = args.into_iter();
724                let color = Color::try_coerce(itr.next().unwrap())?;
725                let intensity = f64::try_coerce(itr.next().unwrap())?;
726                Ok(Material::emissive(color, intensity).to_pax_value())
727            }),
728        );
729        register_function(
730            "Material".to_string(),
731            "custom".to_string(),
732            std::sync::Arc::new(|args| {
733                if args.len() != 1 {
734                    return Err("Expected 1 argument for function Material::custom".to_string());
735                }
736                let params = MaterialParams::try_coerce(args.into_iter().next().unwrap())?;
737                Ok(Material::custom(params).to_pax_value())
738            }),
739        );
740        register_function(
741            "Material".to_string(),
742            "unlit".to_string(),
743            std::sync::Arc::new(|args| {
744                if !args.is_empty() {
745                    return Err("Expected 0 arguments for function Material::unlit".to_string());
746                }
747                Ok(Material::unlit().to_pax_value())
748            }),
749        );
750    }
751}
752
753/// Resolved ambient light for one logical canvas layer.
754#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
755#[serde(crate = "crate::serde")]
756pub struct SceneAmbientLight {
757    /// Ambient color.
758    pub color: Color,
759    /// Ambient intensity.
760    pub intensity: f64,
761}
762
763impl Default for SceneAmbientLight {
764    fn default() -> Self {
765        Self {
766            color: Color::WHITE,
767            intensity: 1.0,
768        }
769    }
770}
771
772impl Interpolatable for SceneAmbientLight {
773    fn interpolate(&self, other: &Self, t: f64) -> Self {
774        Self {
775            color: self.color.interpolate(&other.color, t),
776            intensity: self.intensity.interpolate(&other.intensity, t),
777        }
778    }
779}
780
781impl HelperFunctions for SceneAmbientLight {}
782
783/// Resolved light contribution for one logical canvas layer.
784#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
785#[serde(crate = "crate::serde")]
786pub struct SceneLight {
787    /// Positional or directional light shape.
788    pub shape: LightShape,
789    /// Position in logical canvas pixels for point lights.
790    pub position: Vector3,
791    /// Direction in scene space for directional lights.
792    pub direction: Vector3,
793    /// Light color.
794    pub color: Color,
795    /// Light intensity.
796    pub intensity: f64,
797    /// Point light radius in logical pixels.
798    pub radius: f64,
799    /// Whether this light contributes.
800    pub enabled: bool,
801}
802
803impl Default for SceneLight {
804    fn default() -> Self {
805        Self {
806            shape: LightShape::Point,
807            position: Vector3::default(),
808            direction: Vector3::new(0.0, 0.0, -1.0),
809            color: Color::WHITE,
810            intensity: 1.0,
811            radius: 240.0,
812            enabled: true,
813        }
814    }
815}
816
817impl Interpolatable for SceneLight {
818    fn interpolate(&self, other: &Self, t: f64) -> Self {
819        Self {
820            shape: if t < 1.0 { self.shape } else { other.shape },
821            position: self.position.interpolate(&other.position, t),
822            direction: self.direction.interpolate(&other.direction, t),
823            color: self.color.interpolate(&other.color, t),
824            intensity: self.intensity.interpolate(&other.intensity, t),
825            radius: self.radius.interpolate(&other.radius, t),
826            enabled: if t < 1.0 { self.enabled } else { other.enabled },
827        }
828    }
829}
830
831impl HelperFunctions for SceneLight {}
832
833/// Resolved lighting state for one logical canvas layer.
834#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
835#[serde(crate = "crate::serde")]
836pub struct SceneLighting {
837    /// Whether authored lights or ambient overrides are present.
838    pub active: bool,
839    /// Whether `ambient` came from an enabled authored `AmbientLight`.
840    ///
841    /// The default ambient is only applied to primitives with at least one
842    /// eligible direct light. An authored ambient remains layer-wide.
843    pub ambient_is_authored: bool,
844    /// Singleton ambient contribution.
845    pub ambient: SceneAmbientLight,
846    /// Positional and directional light contributions.
847    pub lights: Vec<SceneLight>,
848}
849
850impl Default for SceneLighting {
851    fn default() -> Self {
852        Self {
853            active: false,
854            ambient_is_authored: false,
855            ambient: SceneAmbientLight::default(),
856            lights: vec![],
857        }
858    }
859}
860
861impl SceneLighting {
862    /// Maximum number of simultaneously enabled lights in one target canvas layer.
863    pub const MAX_LIGHTS: usize = 8;
864
865    /// Ambient intensity used when point/directional lights exist but no explicit ambient override exists.
866    pub const DEFAULT_AMBIENT_INTENSITY: f64 = 0.35;
867
868    /// Returns lighting that preserves unlit legacy rendering.
869    pub fn identity() -> Self {
870        Self::default()
871    }
872
873    /// Creates active lighting with Pax's default ambient term.
874    pub fn with_default_ambient(lights: Vec<SceneLight>) -> Self {
875        Self {
876            active: !lights.is_empty(),
877            ambient_is_authored: false,
878            ambient: SceneAmbientLight {
879                color: Color::WHITE,
880                intensity: Self::DEFAULT_AMBIENT_INTENSITY,
881            },
882            lights,
883        }
884    }
885}
886
887impl Interpolatable for SceneLighting {}
888impl HelperFunctions for SceneLighting {}
889
890impl Interpolatable for Paint {
891    fn interpolate(&self, other: &Self, t: f64) -> Self {
892        if let (Self::Solid(from), Self::Solid(to)) = (self, other) {
893            return Self::Solid(from.interpolate(to, t));
894        }
895        if t <= 0.0 || self == other {
896            return self.clone();
897        }
898        if t >= 1.0 {
899            return other.clone();
900        }
901        Self::blend(vec![(self.clone(), 1.0 - t), (other.clone(), t)])
902    }
903}
904
905/// Describes a linear gradient fill with a start and end point, and a list of color stops.
906///
907/// Pax templates canonically author each point as `[x, y]`: magic index `0`
908/// is the horizontal coordinate and index `1` is the vertical coordinate.
909#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Hash)]
910#[serde(crate = "crate::serde")]
911pub struct LinearGradient {
912    /// Gradient start point in the primitive's local coordinate space.
913    pub start: (Size, Size),
914    /// Gradient end point in the primitive's local coordinate space.
915    pub end: (Size, Size),
916    /// Ordered color stops along the gradient.
917    pub stops: Vec<GradientStop>,
918}
919
920/// A colored anchor in a [`MeshGradient`]. Position uses ordinary pixels, percentages,
921/// or combined units, relative to the complete consumer geometry's paint bounds.
922/// Rows describe connectivity; anchors in a row may have different y coordinates.
923#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Hash)]
924#[serde(crate = "crate::serde")]
925pub struct MeshPoint {
926    /// Local x/y coordinates relative to the complete paint bounds.
927    pub position: (Size, Size),
928    /// Anchor color, including alpha.
929    pub color: Color,
930}
931
932impl Interpolatable for MeshPoint {
933    fn interpolate(&self, other: &Self, t: f64) -> Self {
934        Self {
935            position: self.position.interpolate(&other.position, t),
936            color: self.color.interpolate(&other.color, t),
937        }
938    }
939}
940
941/// Smooth mesh paint with 2–8 equally sized rows, each containing 2–8 anchors.
942/// Automatic shared Bézier controls interpolate the anchors and their premultiplied
943/// encoded-sRGB colors. Outside the covered mesh the paint is transparent. Folded
944/// patches replace earlier patches in row-major order. Supported by the WGPU renderer.
945/// Animate positions/colors to morph; whole [`Paint`] transitions remain crossfades.
946#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Hash)]
947#[serde(crate = "crate::serde")]
948pub struct MeshGradient {
949    /// A rectangular lattice; adjacent entries and rows define shared patch edges.
950    pub rows: Vec<Vec<MeshPoint>>,
951}
952
953impl Interpolatable for MeshGradient {}
954
955impl MeshGradient {
956    /// Checks connectivity and finite inputs. An invalid mesh paints nothing; it is never truncated.
957    pub fn validate(&self) -> Result<(), String> {
958        let width = self.rows.first().map_or(0, Vec::len);
959        if !(2..=8).contains(&self.rows.len())
960            || !(2..=8).contains(&width)
961            || self.rows.iter().any(|row| row.len() != width)
962        {
963            return Err("mesh gradients require 2–8 equal-length rows of 2–8 anchors".into());
964        }
965        let finite_size = |size: &Size| match size {
966            Size::Pixels(v) | Size::Percent(v) => v.to_float().is_finite(),
967            Size::Combined(px, percent) => {
968                px.to_float().is_finite() && percent.to_float().is_finite()
969            }
970        };
971        if self.rows.iter().flatten().any(|p| {
972            !finite_size(&p.position.0)
973                || !finite_size(&p.position.1)
974                || p.color.to_rgba_0_1().iter().any(|c| !c.is_finite())
975        }) {
976            return Err("mesh gradient positions and colors must be finite".into());
977        }
978        Ok(())
979    }
980}
981
982#[cfg(test)]
983mod mesh_value_tests {
984    use super::*;
985
986    #[test]
987    fn mesh_values_roundtrip_and_opacity_scales_the_sampled_field() {
988        let point = MeshPoint {
989            position: (
990                Size::Combined(5.into(), 40.into()),
991                Size::Percent(70.into()),
992            ),
993            color: Color::CYAN,
994        };
995        let mesh = MeshGradient {
996            rows: vec![vec![point.clone(); 2]; 2],
997        };
998        assert!(mesh.validate().is_ok());
999        let paint = Paint::MeshGradient(mesh.clone());
1000        assert_eq!(
1001            Paint::try_coerce(paint.clone().to_pax_value()).unwrap(),
1002            paint
1003        );
1004        assert_eq!(
1005            MeshPoint::try_coerce(point.clone().to_pax_value()).unwrap(),
1006            point
1007        );
1008        let Paint::Blend(terms) = paint.with_alpha_factor(0.25) else {
1009            panic!("post-sampling opacity")
1010        };
1011        assert_eq!(terms[0], (paint.clone(), 0.25));
1012        assert_eq!(paint.with_alpha_factor(0.0).max_alpha_0_1(), 0.0);
1013        assert_eq!(paint.interpolate(&Paint::Solid(Color::WHITE), 0.0), paint);
1014        assert!(matches!(
1015            paint.interpolate(&Paint::Solid(Color::WHITE), 0.5),
1016            Paint::Blend(_)
1017        ));
1018        let mut invalid = mesh;
1019        invalid.rows[0].pop();
1020        assert!(invalid.validate().is_err());
1021    }
1022
1023    #[test]
1024    fn malformed_mesh_coordinates_never_panic_or_truncate() {
1025        for coordinates in [
1026            vec![],
1027            vec![Size::Percent(0.into())],
1028            vec![Size::Percent(0.into()); 3],
1029        ] {
1030            let value = PaxValue::Object(vec![
1031                ("position".into(), coordinates.to_pax_value()),
1032                ("color".into(), Color::CYAN.to_pax_value()),
1033            ]);
1034            assert!(MeshPoint::try_coerce(value).is_err());
1035        }
1036    }
1037}
1038
1039/// A focal radial gradient, growing from `start` to the circle at `end` with `radius`.
1040///
1041/// Pax templates canonically author each point as `[x, y]`: magic index `0`
1042/// is the horizontal coordinate and index `1` is the vertical coordinate.
1043#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1044#[serde(crate = "crate::serde")]
1045pub struct RadialGradient {
1046    /// Center of the outer circle in the primitive's local coordinate space.
1047    pub end: (Size, Size),
1048    /// Focal point (0% stop). Equal start/end points give concentric circles.
1049    pub start: (Size, Size),
1050    /// Outer radius in local logical pixels. Nonpositive/nonfinite radii paint nothing.
1051    pub radius: f64,
1052    /// Ordered color stops along the gradient.
1053    pub stops: Vec<GradientStop>,
1054}
1055
1056/// Runtime geometry shared by radial color fills and painted alpha masks.
1057/// Coordinates are local logical pixels, before the element's affine transform.
1058#[derive(Debug, Clone, Copy, PartialEq)]
1059pub struct ResolvedRadialGradient {
1060    /// Point corresponding to the 0% stop.
1061    pub focal_point: kurbo::Point,
1062    /// Center of the circle corresponding to the 100% stop.
1063    pub center: kurbo::Point,
1064    /// Radius of the 100% circle in local logical pixels.
1065    pub radius: f64,
1066}
1067
1068impl RadialGradient {
1069    /// Resolves points against the actual shape bounds, including their origin.
1070    /// Invalid geometry paints nothing on all backends. The authored radius is
1071    /// independent of the distance between the focal point and the center.
1072    pub fn resolve_geometry(&self, rect: kurbo::Rect) -> Option<ResolvedRadialGradient> {
1073        let bounds = (rect.width(), rect.height());
1074        let resolve = |p: (Size, Size)| {
1075            kurbo::Point::new(
1076                rect.x0 + p.0.evaluate(bounds, Axis::X),
1077                rect.y0 + p.1.evaluate(bounds, Axis::Y),
1078            )
1079        };
1080        let focal_point = resolve(self.start);
1081        let center = resolve(self.end);
1082        if self.radius <= 0.0
1083            || ![
1084                self.radius,
1085                focal_point.x,
1086                focal_point.y,
1087                center.x,
1088                center.y,
1089            ]
1090            .into_iter()
1091            .all(f64::is_finite)
1092        {
1093            return None;
1094        }
1095        Some(ResolvedRadialGradient {
1096            focal_point,
1097            center,
1098            radius: self.radius,
1099        })
1100    }
1101}
1102
1103/// A color stop for a gradient fill, defined by a position (% or px) and a color.
1104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Hash)]
1105#[serde(crate = "crate::serde")]
1106pub struct GradientStop {
1107    /// Stop position, conventionally expressed as a percentage along the gradient.
1108    pub position: Size,
1109    /// Color at this stop.
1110    pub color: Color,
1111}
1112
1113impl GradientStop {
1114    /// Constructs a gradient stop at `position`.
1115    pub fn get(color: Color, position: Size) -> GradientStop {
1116        GradientStop { position, color }
1117    }
1118
1119    /// Returns a copy of this stop with alpha multiplied by `factor`.
1120    pub fn with_alpha_factor(&self, factor: f64) -> GradientStop {
1121        GradientStop {
1122            position: self.position.clone(),
1123            color: self.color.with_alpha_factor(factor),
1124        }
1125    }
1126}
1127
1128impl Default for Paint {
1129    fn default() -> Self {
1130        Self::Solid(Color::default())
1131    }
1132}
1133
1134impl Paint {
1135    /// Constructs a normalized mixture of paints. Nested mixtures are flattened
1136    /// and identical endpoints merged, so repeated interruptions between a fixed
1137    /// set of themes do not accumulate a history of blend nodes. Nonpositive or
1138    /// nonfinite weights are ignored; an empty mixture is transparent.
1139    ///
1140    /// Sampling cost grows with the number of distinct endpoints still visible.
1141    /// A completed interpolation returns its destination paint directly.
1142    pub fn blend(terms: Vec<(Paint, f64)>) -> Self {
1143        fn append(out: &mut Vec<(Paint, f64)>, terms: Vec<(Paint, f64)>, scale: f64) {
1144            let max = terms
1145                .iter()
1146                .map(|(_, w)| *w)
1147                .filter(|w| w.is_finite())
1148                .fold(0.0, f64::max);
1149            if max <= 0.0 {
1150                return;
1151            }
1152            let sum: f64 = terms
1153                .iter()
1154                .map(|(_, w)| *w)
1155                .filter(|w| w.is_finite() && *w > 0.0)
1156                .map(|w| w / max)
1157                .sum();
1158            for (fill, weight) in terms {
1159                if !weight.is_finite() || weight <= 0.0 {
1160                    continue;
1161                }
1162                let weight = scale * (weight / max) / sum;
1163                if weight == 0.0 {
1164                    continue;
1165                }
1166                if let Paint::Blend(terms) = fill {
1167                    append(out, terms, weight);
1168                } else if let Some((_, existing)) = out.iter_mut().find(|(f, _)| *f == fill) {
1169                    *existing += weight;
1170                } else {
1171                    out.push((fill, weight));
1172                }
1173            }
1174        }
1175        let mut result = Vec::new();
1176        append(&mut result, terms, 1.0);
1177        if result.is_empty() {
1178            return Paint::Solid(Color::from_rgba_0_1([0.0; 4]));
1179        }
1180        if result.len() == 1 {
1181            result.pop().unwrap().0
1182        } else {
1183            Paint::Blend(result)
1184        }
1185    }
1186
1187    /// Representative solid color for native text, which currently uses the
1188    /// first stop of a gradient. Mixtures crossfade those representative colors.
1189    pub fn representative_color(&self) -> Color {
1190        match self {
1191            Self::Solid(color) => color.clone(),
1192            Self::LinearGradient(g) => g.stops.first().map(|s| s.color.clone()).unwrap_or_default(),
1193            Self::RadialGradient(g) => g.stops.first().map(|s| s.color.clone()).unwrap_or_default(),
1194            // Native text has no mesh paint support; do not substitute a corner color.
1195            Self::MeshGradient(_) => Color::TRANSPARENT,
1196            Self::Blend(terms) => Color::blend_premultiplied(
1197                terms
1198                    .iter()
1199                    .map(|(fill, weight)| (fill.representative_color(), *weight)),
1200            ),
1201        }
1202    }
1203
1204    fn gradient_stop_position_0_1(position: &Size) -> Option<f64> {
1205        match position {
1206            Size::Percent(p) => Some((p.to_float() / 100.0).clamp(0.0, 1.0)),
1207            Size::Pixels(_) | Size::Combined(_, _) => None,
1208        }
1209    }
1210
1211    fn integrated_gradient_alpha_0_1(stops: &[GradientStop]) -> f64 {
1212        if stops.is_empty() {
1213            return 0.0;
1214        }
1215
1216        let mut positioned_stops = stops
1217            .iter()
1218            .filter_map(|stop| {
1219                Self::gradient_stop_position_0_1(&stop.position)
1220                    .map(|position| (position, stop.color.alpha_0_1()))
1221            })
1222            .collect::<Vec<_>>();
1223
1224        if positioned_stops.is_empty() {
1225            return stops
1226                .iter()
1227                .map(|stop| stop.color.alpha_0_1())
1228                .fold(0.0, f64::max);
1229        }
1230
1231        positioned_stops.sort_by(|lhs, rhs| lhs.0.total_cmp(&rhs.0));
1232
1233        if positioned_stops
1234            .first()
1235            .map(|stop| stop.0 > 0.0)
1236            .unwrap_or(false)
1237        {
1238            let alpha = positioned_stops[0].1;
1239            positioned_stops.insert(0, (0.0, alpha));
1240        }
1241
1242        if positioned_stops
1243            .last()
1244            .map(|stop| stop.0 < 1.0)
1245            .unwrap_or(false)
1246        {
1247            let alpha = positioned_stops.last().map(|stop| stop.1).unwrap_or(0.0);
1248            positioned_stops.push((1.0, alpha));
1249        }
1250
1251        let mut integrated_alpha = 0.0;
1252        for window in positioned_stops.windows(2) {
1253            let [(start_pos, start_alpha), (end_pos, end_alpha)] = window else {
1254                continue;
1255            };
1256            let span = (end_pos - start_pos).clamp(0.0, 1.0);
1257            if span <= f64::EPSILON {
1258                continue;
1259            }
1260            integrated_alpha += (start_alpha + end_alpha) * 0.5 * span;
1261        }
1262
1263        integrated_alpha.clamp(0.0, 1.0)
1264    }
1265
1266    // Converts a Pax point into Piet's unit-gradient coordinate system.
1267    pub fn to_unit_point((x, y): (Size, Size), (width, height): (f64, f64)) -> UnitPoint {
1268        let normalized_x = match x {
1269            Size::Pixels(val) => val.to_float() / width,
1270            Size::Percent(val) => val.to_float() / 100.0,
1271            Size::Combined(pix, per) => (pix.to_float() / width) + (per.to_float() / 100.0),
1272        };
1273
1274        let normalized_y = match y {
1275            Size::Pixels(val) => val.to_float() / height,
1276            Size::Percent(val) => val.to_float() / 100.0,
1277            Size::Combined(pix, per) => (pix.to_float() / width) + (per.to_float() / 100.0),
1278        };
1279        UnitPoint::new(normalized_x, normalized_y)
1280    }
1281
1282    // Converts Pax gradient stops into Piet's gradient-stop representation.
1283    pub fn to_piet_gradient_stops(stops: Vec<GradientStop>) -> Vec<piet::GradientStop> {
1284        let mut ret = Vec::new();
1285        for gradient_stop in stops {
1286            match gradient_stop.position {
1287                Size::Pixels(_) => {
1288                    panic!("Gradient stops must be specified in percentages");
1289                }
1290                Size::Percent(p) => {
1291                    ret.push(piet::GradientStop {
1292                        pos: (p.to_float() / 100.0) as f32,
1293                        color: gradient_stop.color.to_piet_color(),
1294                    });
1295                }
1296                Size::Combined(_, _) => {
1297                    panic!("Gradient stops must be specified in percentages");
1298                }
1299            }
1300        }
1301        ret
1302    }
1303
1304    #[allow(non_snake_case)]
1305    /// Constructs a linear gradient paint.
1306    ///
1307    /// Pax templates should normally use `@gradient`. When this helper is
1308    /// needed explicitly, pass `start` and `end` as `[x, y]` lists.
1309    pub fn linearGradient(
1310        start: (Size, Size),
1311        end: (Size, Size),
1312        stops: Vec<GradientStop>,
1313    ) -> Paint {
1314        Paint::LinearGradient(LinearGradient { start, end, stops })
1315    }
1316
1317    /// Returns a copy of this paint with alpha multiplied by `factor`.
1318    pub fn with_alpha_factor(&self, factor: f64) -> Paint {
1319        match self {
1320            Paint::Blend(terms) => Paint::Blend(
1321                terms
1322                    .iter()
1323                    .map(|(fill, weight)| (fill.with_alpha_factor(factor), *weight))
1324                    .collect(),
1325            ),
1326            Paint::Solid(color) => Paint::Solid(color.with_alpha_factor(factor)),
1327            Paint::MeshGradient(_) => {
1328                // Scale the sampled field after cubic clamping. Scaling anchor alpha would
1329                // change overshoot regions and is not equivalent to paint opacity.
1330                let factor = factor.clamp(0.0, 1.0);
1331                Paint::blend(vec![
1332                    (self.clone(), factor),
1333                    (Paint::Solid(Color::TRANSPARENT), 1.0 - factor),
1334                ])
1335            }
1336            Paint::LinearGradient(gradient) => Paint::LinearGradient(LinearGradient {
1337                start: gradient.start.clone(),
1338                end: gradient.end.clone(),
1339                stops: gradient
1340                    .stops
1341                    .iter()
1342                    .map(|stop| stop.with_alpha_factor(factor))
1343                    .collect(),
1344            }),
1345            Paint::RadialGradient(gradient) => Paint::RadialGradient(RadialGradient {
1346                start: gradient.start.clone(),
1347                end: gradient.end.clone(),
1348                radius: gradient.radius,
1349                stops: gradient
1350                    .stops
1351                    .iter()
1352                    .map(|stop| stop.with_alpha_factor(factor))
1353                    .collect(),
1354            }),
1355        }
1356    }
1357
1358    /// Returns a conservative upper bound for this paint's alpha. For mixtures,
1359    /// endpoint maxima need not occur at the same point.
1360    pub fn max_alpha_0_1(&self) -> f64 {
1361        match self {
1362            Paint::Blend(terms) => terms
1363                .iter()
1364                .map(|(fill, weight)| fill.max_alpha_0_1() * weight)
1365                .sum::<f64>()
1366                .clamp(0.0, 1.0),
1367            Paint::Solid(color) => color.alpha_0_1(),
1368            Paint::MeshGradient(mesh) => {
1369                // Cubics can overshoot anchor alpha. Only the all-transparent field is
1370                // certainly invisible; max(anchor alpha) is not a conservative bound.
1371                if mesh
1372                    .rows
1373                    .iter()
1374                    .flatten()
1375                    .any(|p| p.color.alpha_0_1() > 0.0)
1376                {
1377                    1.0
1378                } else {
1379                    0.0
1380                }
1381            }
1382            Paint::LinearGradient(gradient) => gradient
1383                .stops
1384                .iter()
1385                .map(|stop| stop.color.alpha_0_1())
1386                .fold(0.0, f64::max),
1387            Paint::RadialGradient(gradient) => gradient
1388                .stops
1389                .iter()
1390                .map(|stop| stop.color.alpha_0_1())
1391                .fold(0.0, f64::max),
1392        }
1393    }
1394
1395    /// Estimates the alpha coverage contributed by this fill.
1396    pub fn coverage_alpha_0_1(&self) -> f64 {
1397        match self {
1398            Paint::Blend(terms) => terms
1399                .iter()
1400                .map(|(fill, weight)| fill.coverage_alpha_0_1() * weight)
1401                .sum::<f64>()
1402                .clamp(0.0, 1.0),
1403            Paint::Solid(color) => color.alpha_0_1(),
1404            Paint::LinearGradient(gradient) => Self::integrated_gradient_alpha_0_1(&gradient.stops),
1405            Paint::RadialGradient(gradient) => Self::integrated_gradient_alpha_0_1(&gradient.stops),
1406            Paint::MeshGradient(_) => self.max_alpha_0_1(),
1407        }
1408    }
1409}
1410
1411#[cfg(test)]
1412mod fill_coverage_tests {
1413    fn gradient(color: Color) -> Paint {
1414        Paint::linearGradient(
1415            (Size::Percent(0.into()), Size::Percent(0.into())),
1416            (Size::Percent(100.into()), Size::Percent(100.into())),
1417            vec![GradientStop::get(color, Size::Percent(0.into()))],
1418        )
1419    }
1420
1421    #[test]
1422    fn transparent_paint_crossfade_uses_premultiplied_color_and_alpha() {
1423        use super::*;
1424        let from = gradient(Color::from_rgba_0_1([1.0, 0.0, 0.0, 0.0]));
1425        let to = Paint::Solid(Color::from_rgba_0_1([0.0, 0.0, 1.0, 0.5]));
1426        let halfway = from.interpolate(&to, 0.5);
1427        assert_eq!(
1428            halfway.representative_color().to_rgba_0_1(),
1429            [0.0, 0.0, 1.0, 0.25]
1430        );
1431        assert_eq!(halfway.max_alpha_0_1(), 0.25);
1432        assert_eq!(halfway.with_alpha_factor(0.5).max_alpha_0_1(), 0.125);
1433        assert_eq!(from.interpolate(&to, 0.0), from);
1434        assert_eq!(from.interpolate(&to, 1.0), to);
1435    }
1436
1437    #[test]
1438    fn interrupted_gradient_reversals_remain_flat_continuous_and_bounded() {
1439        use super::*;
1440        let dark = gradient(Color::BLACK);
1441        let light = gradient(Color::WHITE);
1442        let mut displayed = dark.clone();
1443        for i in 0..1000 {
1444            let target = if i % 2 == 0 { &light } else { &dark };
1445            assert_eq!(displayed.interpolate(target, 0.0), displayed);
1446            let before = displayed.representative_color().to_rgba_0_1();
1447            displayed = displayed.interpolate(target, 0.07);
1448            let after = displayed.representative_color().to_rgba_0_1();
1449            let expected = target.representative_color().to_rgba_0_1();
1450            for channel in 0..4 {
1451                assert!(
1452                    (after[channel] - (before[channel] * 0.93 + expected[channel] * 0.07)).abs()
1453                        < 1e-12
1454                );
1455            }
1456            let Paint::Blend(terms) = &displayed else {
1457                panic!("expected mixture");
1458            };
1459            assert_eq!(terms.len(), 2);
1460            assert!(terms
1461                .iter()
1462                .all(|(fill, _)| !matches!(fill, Paint::Blend(_))));
1463            let roundtrip = Paint::try_coerce(displayed.clone().to_pax_value()).unwrap();
1464            for (a, b) in roundtrip
1465                .representative_color()
1466                .to_rgba_0_1()
1467                .into_iter()
1468                .zip(after)
1469            {
1470                assert!((a - b).abs() < 1e-12);
1471            }
1472        }
1473        assert_eq!(displayed.interpolate(&light, 1.0), light);
1474    }
1475
1476    #[test]
1477    fn crossfade_keeps_different_gradient_kinds_geometry_and_stop_lists() {
1478        use super::*;
1479        let from = gradient(Color::BLACK);
1480        let to = Paint::RadialGradient(RadialGradient {
1481            start: (Size::Pixels(12.into()), Size::Percent(50.into())),
1482            end: (Size::Percent(100.into()), Size::Percent(50.into())),
1483            radius: 0.5,
1484            stops: vec![
1485                GradientStop::get(Color::WHITE, Size::Percent(20.into())),
1486                GradientStop::get(Color::BLACK, Size::Percent(80.into())),
1487            ],
1488        });
1489        assert_eq!(
1490            from.interpolate(&to, 0.25),
1491            Paint::Blend(vec![(from, 0.75), (to, 0.25)])
1492        );
1493    }
1494
1495    #[test]
1496    fn solid_fill_interpolates_rgba_channels() {
1497        use super::*;
1498        let from = Paint::Solid(Color::BLACK);
1499        let to = Paint::Solid(Color::WHITE);
1500        assert_eq!(
1501            from.interpolate(&to, 0.5),
1502            Paint::Solid(Color::BLACK.interpolate(&Color::WHITE, 0.5))
1503        );
1504    }
1505    use super::{Color, ColorChannel, GradientStop, LinearGradient, Numeric, Paint, Size};
1506
1507    fn rgba_alpha(alpha: u8) -> Color {
1508        Color::rgba(
1509            ColorChannel::Integer(0),
1510            ColorChannel::Integer(0),
1511            ColorChannel::Integer(0),
1512            ColorChannel::Integer(alpha),
1513        )
1514    }
1515
1516    #[test]
1517    fn coverage_alpha_matches_solid_alpha() {
1518        let fill = Paint::Solid(rgba_alpha(128));
1519        assert!((fill.coverage_alpha_0_1() - (128.0 / 255.0)).abs() < 1e-6);
1520    }
1521
1522    #[test]
1523    fn coverage_alpha_integrates_gradient_stops() {
1524        let fill = Paint::LinearGradient(LinearGradient {
1525            start: (
1526                Size::Percent(Numeric::F64(0.0)),
1527                Size::Percent(Numeric::F64(50.0)),
1528            ),
1529            end: (
1530                Size::Percent(Numeric::F64(100.0)),
1531                Size::Percent(Numeric::F64(50.0)),
1532            ),
1533            stops: vec![
1534                GradientStop::get(rgba_alpha(0), Size::Percent(Numeric::F64(0.0))),
1535                GradientStop::get(rgba_alpha(255), Size::Percent(Numeric::F64(50.0))),
1536                GradientStop::get(rgba_alpha(0), Size::Percent(Numeric::F64(100.0))),
1537            ],
1538        });
1539        assert!((fill.coverage_alpha_0_1() - 0.5).abs() < 1e-6);
1540    }
1541}
1542
1543/// Stable local reference frame for paint, independent of outline width and reveal.
1544/// A collapsed axis gets a centered one-logical-pixel span without adding coverage.
1545pub fn paint_bounds(mut rect: kurbo::Rect) -> Option<kurbo::Rect> {
1546    if ![rect.x0, rect.y0, rect.x1, rect.y1]
1547        .into_iter()
1548        .all(f64::is_finite)
1549    {
1550        log::warn!("Paint geometry must be finite");
1551        return None;
1552    }
1553    if rect.width() == 0.0 {
1554        rect.x0 -= 0.5;
1555        rect.x1 += 0.5;
1556    }
1557    if rect.height() == 0.0 {
1558        rect.y0 -= 0.5;
1559        rect.y1 += 0.5;
1560    }
1561    Some(rect)
1562}
1563
1564impl Fill {
1565    pub(crate) fn validate_layer(&self) -> Result<(), String> {
1566        Ok(())
1567    }
1568}
1569impl Stroke {
1570    pub(crate) fn validate_layer(&self) -> Result<(), String> {
1571        if matches!(self.width.get(), Size::Pixels(_)) {
1572            Ok(())
1573        } else {
1574            Err("Stroke.width: expected logical pixels; percentage widths are unsupported".into())
1575        }
1576    }
1577}
1578
1579#[cfg(test)]
1580mod appearance_tests {
1581    use super::*;
1582    use crate::properties::drain_effects;
1583
1584    #[test]
1585    fn appearance_defaults_shorthand_and_errors() {
1586        let red = Color::RED.to_pax_value();
1587        let fills = Vec::<Fill>::try_coerce(red.clone()).unwrap();
1588        let strokes = Vec::<Stroke>::try_coerce(red).unwrap();
1589        assert_eq!(fills[0].paint.get(), Paint::Solid(Color::RED));
1590        assert_eq!(strokes[0].width_pixels(), 1.0);
1591        assert_eq!(fills[0].material.get(), Material::default());
1592        assert_eq!(fills[0].opacity.get().to_float_0_1(), 1.0);
1593        assert!(Vec::<Fill>::try_coerce(PaxValue::Vec(vec![]))
1594            .unwrap()
1595            .is_empty());
1596        assert!(Vec::<Numeric>::try_coerce(1.to_pax_value()).is_err());
1597        for invalid in [
1598            Stroke::default().to_pax_value(),
1599            PaxValue::Vec(vec![]),
1600            PaxValue::Option(Box::new(None)),
1601            PaxValue::Object(vec![("color".into(), Color::RED.to_pax_value())]),
1602        ] {
1603            let error =
1604                Vec::<Fill>::try_coerce(PaxValue::Vec(vec![Color::RED.to_pax_value(), invalid]))
1605                    .unwrap_err();
1606            assert!(error.contains("[1]"), "{error}");
1607        }
1608        assert!(Stroke::try_coerce(PaxValue::Object(vec![(
1609            "width".into(),
1610            Size::Percent(10.into()).to_pax_value()
1611        )]))
1612        .is_err());
1613    }
1614
1615    #[test]
1616    fn layers_interpolate_by_position_and_switch_lengths_immediately() {
1617        let a = vec![Fill::from(Color::RED)];
1618        let b = vec![Fill::from(Color::BLUE)];
1619        assert_eq!(
1620            a.interpolate(&b, 0.5)[0].paint.get(),
1621            Paint::Solid(Color::RED.interpolate(&Color::BLUE, 0.5))
1622        );
1623        let longer = vec![Fill::from(Color::BLUE), Fill::from(Color::GREEN)];
1624        assert_eq!(a.interpolate(&longer, 0.1), longer);
1625        let a = Stroke::default();
1626        let b = Stroke::default();
1627        b.cap.set(StrokeCap::Round);
1628        assert_eq!(a.interpolate(&b, 0.99).cap.get(), StrokeCap::Butt);
1629        assert_eq!(a.interpolate(&b, 1.0).cap.get(), StrokeCap::Round);
1630    }
1631
1632    #[test]
1633    fn expression_adapter_observes_nested_edits_and_releases_removed_layers() {
1634        let graph = crate::properties::PropertyGraph::new(|| {});
1635        let _entered = graph.enter();
1636        let first = Fill::from(Color::RED);
1637        let source = Property::new(vec![first.clone()]);
1638        let variable = Variable::new_from_typed_property(source.clone());
1639        let read = || Vec::<Fill>::try_coerce(variable.get_as_pax_value()).unwrap();
1640        assert_eq!(read()[0].paint.get(), Paint::Solid(Color::RED));
1641        first.paint.local().set(Paint::Solid(Color::BLUE));
1642        assert!(drain_effects(100) < 100);
1643        assert_eq!(read()[0].paint.get(), Paint::Solid(Color::BLUE));
1644        source.local().set(vec![]);
1645        assert!(read().is_empty());
1646        first.paint.local().set(Paint::Solid(Color::WHITE));
1647        assert!(drain_effects(100) < 100);
1648        assert!(read().is_empty());
1649    }
1650
1651    #[test]
1652    fn nested_appearance_subscriptions_retire_after_replacement_and_disposal() {
1653        let graph = crate::properties::PropertyGraph::new(|| {});
1654        let _entered = graph.enter();
1655        use crate::properties::property_table_total_properties_count;
1656        let baseline = property_table_total_properties_count();
1657        for _ in 0..30 {
1658            let scope = graph.binding_scope();
1659            let _scope = scope.enter();
1660            let source = Property::new(vec![Fill::from(Color::RED)]);
1661            let variable = Variable::new_from_typed_property(source.clone());
1662            variable.get_as_pax_value();
1663            source.local().get()[0]
1664                .opacity
1665                .local()
1666                .set(Opacity::from(0.25));
1667            drain_effects(100);
1668            variable.get_as_pax_value();
1669            source
1670                .local()
1671                .set(vec![Fill::from(Color::BLUE), Fill::from(Color::GREEN)]);
1672            variable.get_as_pax_value();
1673            source.local().set(vec![]);
1674            variable.get_as_pax_value();
1675            // A mounted view owns its shared projections until final unmount.
1676            scope.close();
1677        }
1678        drain_effects(100);
1679        graph.collect();
1680        assert_eq!(property_table_total_properties_count(), baseline);
1681    }
1682
1683    #[test]
1684    fn degenerate_paint_domain_and_nonfinite_opacity_are_finite() {
1685        let rect = paint_bounds(kurbo::Rect::new(25.0, 40.0, 125.0, 40.0)).unwrap();
1686        assert_eq!(rect, kurbo::Rect::new(25.0, 39.5, 125.0, 40.5));
1687        assert!(paint_bounds(kurbo::Rect::new(f64::NAN, 0.0, 1.0, 1.0)).is_none());
1688        for alpha in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1689            assert_eq!(Opacity::from(alpha).to_float_0_1(), 0.0);
1690        }
1691    }
1692}