Batch r2-1 review: a paint keeps its own opacity through unbind and rebind

color_alpha is the part of a solid paint's opacity that is its typed colour's alpha, not a flag:
binding a colour variable takes only that part out, unbinding puts the variable's alpha back in,
and REST import records it. A 50% paint bound to a 50% variable stays at 25% bound or not.
This commit is contained in:
bflatastic
2026-10-24 10:30:49 +07:00
parent 1f9effd25d
commit bd80829a27
8 changed files with 42 additions and 23 deletions
+13 -8
View File
@@ -559,10 +559,11 @@ pub struct Paint {
pub visible: bool,
#[serde(default)]
pub blend: BlendMode,
/// The opacity holds the alpha of a typed solid colour (see [`Paint::solid`]) rather than an
/// opacity set on its own: a colour variable bound to the paint replaces it.
#[serde(default, rename = "colorAlpha", skip_serializing_if = "std::ops::Not::not")]
pub color_alpha: bool,
/// The part of the opacity that is the alpha of a typed solid colour (see [`Paint::solid`]),
/// the rest being an opacity set on its own: a colour variable bound to the paint replaces
/// this part only. 1: all of it is the paint's own.
#[serde(default = "one", rename = "colorAlpha", skip_serializing_if = "is_one")]
pub color_alpha: f32,
}
/// Paints compare by what they paint; where the opacity came from doesn't matter.
@@ -576,6 +577,10 @@ fn yes() -> bool {
true
}
fn is_one(x: &f32) -> bool {
*x == 1.0
}
impl Paint {
pub fn solid(color: Color) -> Self {
Self {
@@ -583,12 +588,12 @@ impl Paint {
opacity: color.a,
visible: true,
blend: BlendMode::Normal,
color_alpha: color.a < 1.0,
color_alpha: color.a,
}
}
pub fn gradient(g: Gradient) -> Self {
Self { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: false }
Self { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: 1.0 }
}
pub fn image(hash: impl Into<String>, mode: ImageScaleMode) -> Self {
@@ -604,13 +609,13 @@ impl Paint {
opacity: 1.0,
visible: true,
blend: BlendMode::Normal,
color_alpha: false,
color_alpha: 1.0,
}
}
/// Layer `source` (a node id) tiled at its own size.
pub fn pattern(source: u64) -> Self {
Self { kind: PaintKind::Pattern(PatternPaint::new(source)), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: false }
Self { kind: PaintKind::Pattern(PatternPaint::new(source)), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: 1.0 }
}
/// The solid colour (with the paint opacity folded into alpha), if solid.
+11
View File
@@ -151,3 +151,14 @@ fn binding_keeps_an_opacity_set_on_the_paint() {
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
assert!(near(alpha(&s, r), 0.25), "{}", alpha(&s, r));
}
#[test]
fn an_unbind_and_rebind_keeps_the_opacity_set_on_the_paint() {
// A paint at 50% bound to a half-transparent colour shows a quarter, unbound or bound again.
let (mut s, r) = setup("#FF000080", "#FF000080");
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5}));
for variable in [json!("tint"), json!(null), json!("tint"), json!(null)] {
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": variable}));
assert!(near(alpha(&s, r), HALF / 2.0), "{variable}: {}", alpha(&s, r));
}
}
+2 -2
View File
@@ -1375,12 +1375,12 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
slot.kind = figcraft_color::PaintKind::Solid { color: c.with_alpha(1.0) };
if c.a < 1.0 {
slot.opacity = c.a;
slot.color_alpha = true;
slot.color_alpha = c.a;
}
}
if let Some(o) = num(p, "opacity") {
slot.opacity = (if o > 1.0 { o / 100.0 } else { o }).clamp(0.0, 1.0) as f32;
slot.color_alpha = false;
slot.color_alpha = 1.0;
}
if let Some(v) = boolean(p, "visible") {
slot.visible = v;
+9 -10
View File
@@ -804,10 +804,10 @@ pub fn bind(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding restarts
/// an opacity that holds the alpha a typed colour had (see `Paint::solid`) at 1, so the variable's
/// alpha applies once; an opacity set on its own, before or while bound, multiplies on top and
/// stays. Unbinding moves the colour's alpha back into the opacity, where a typed colour keeps it.
/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding takes the
/// alpha a typed colour had (see `Paint::solid`) out of the opacity, so the variable's alpha applies
/// once; an opacity set on its own, before or while bound, multiplies on top and stays. Unbinding
/// moves the colour's alpha back into the opacity, where a typed colour keeps it.
pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], field: &str, bind: bool) {
use super::lists::{BoundList, list_binding};
let Some((list, i, "color")) = list_binding(field) else { return };
@@ -820,13 +820,12 @@ pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], fie
&& let PaintKind::Solid { color } = &mut p.kind
{
if bind {
if p.color_alpha {
p.opacity = 1.0;
}
p.color_alpha = false;
} else if color.a < 1.0 {
// What's left is the opacity set on its own (unknown under a colour alpha of 0: 1).
p.opacity = if p.color_alpha > 0.0 { (p.opacity / p.color_alpha).clamp(0.0, 1.0) } else { 1.0 };
p.color_alpha = 1.0;
} else {
p.opacity *= color.a;
p.color_alpha = true;
p.color_alpha *= color.a;
}
color.a = 1.0;
}
+1 -1
View File
@@ -106,7 +106,7 @@ fn themed() -> Document {
g.stops = vec![ColorStop { position: 0.0, color: Color::BLACK }, ColorStop { position: 1.0, color: Color::WHITE }];
n.fills = vec![
solid(0.5, 0.5, 0.5),
Paint { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: false },
Paint { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: 1.0 },
];
n.strokes = vec![solid(0.0, 0.0, 0.0), solid(1.0, 0.0, 0.0)];
n.effects = vec![Effect {
+1 -1
View File
@@ -74,7 +74,7 @@ fn paint(r: &mut Lcg, image: &str) -> Paint {
p.kind
}
};
Paint { kind, opacity: r.pick(&[1.0, 0.5, 0.25]), visible: r.chance(85), blend: r.pick(&BLENDS), color_alpha: false }
Paint { kind, opacity: r.pick(&[1.0, 0.5, 0.25]), visible: r.chance(85), blend: r.pick(&BLENDS), color_alpha: 1.0 }
}
fn effect(r: &mut Lcg) -> Effect {
+4 -1
View File
@@ -123,10 +123,13 @@ pub(super) fn paints(cx: &mut Ctx, v: &Value, k: &str) -> Option<Vec<Paint>> {
fn paint(cx: &mut Ctx, p: &Value) -> Option<Paint> {
let ty = text(p, "type")?;
let mut opacity = num(p, "opacity").unwrap_or(1.0).clamp(0.0, 1.0) as f32;
// The colour's alpha goes into the opacity as for a typed colour (`Paint::solid`).
let mut color_alpha = 1.0;
let kind = match ty {
"SOLID" => {
let c = p.get("color").and_then(color).unwrap_or(Color::BLACK);
opacity *= c.a;
color_alpha = c.a;
PaintKind::Solid { color: c.with_alpha(1.0) }
}
"GRADIENT_LINEAR" => PaintKind::Gradient(gradient(GradientKind::Linear, p)),
@@ -139,7 +142,7 @@ fn paint(cx: &mut Ctx, p: &Value) -> Option<Paint> {
return None;
}
};
Some(Paint { kind, opacity, visible: flag(p, "visible").unwrap_or(true), blend: paint_blend(p), color_alpha: false })
Some(Paint { kind, opacity, visible: flag(p, "visible").unwrap_or(true), blend: paint_blend(p), color_alpha })
}
/// Gradient handles are already in the node's unit box (start, end, width), like ours.
+1
View File
@@ -162,6 +162,7 @@ fn paints_gradients_and_image_placeholders() {
let s = &named(&doc, "Solid").fills[0];
assert!(matches!(s.kind, PaintKind::Solid { color } if color.r == 1.0 && color.a == 1.0));
assert!(close(f64::from(s.opacity), 0.25), "paint opacity × colour alpha");
assert!(close(f64::from(s.color_alpha), 0.5), "the colour's part, which a bound variable replaces");
assert!(!s.visible && s.blend == BlendMode::Screen);
let PaintKind::Gradient(g) = &named(&doc, "Linear").fills[0].kind else { panic!() };
assert_eq!((g.kind, g.from, g.to, g.width), (GradientKind::Linear, (0.0, 0.5), (1.0, 0.5), (0.0, 1.0)));