M1: mask types: alpha, vector outline and luminance masks (#17)

- Layers gain `maskType` (alpha by default, so files are unchanged): a vector mask clips by the
  mask layer's outline whatever its fills and opacity; a luminance mask shows content where the
  mask is bright and hides it where it is dark.
- `node.set {maskType}` (validated), `node.get` reports it, and the Appearance section shows a
  Mask dropdown when the selection is masks.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat
2026-10-10 03:45:45 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 076c25f6df
commit 840d3ac5d2
10 changed files with 149 additions and 5 deletions
+1 -1
View File
@@ -135,7 +135,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Paint | 7 | 65% | colour profiles (Display P3), paint style picker polish, image adjustment presets | 6–10 |
| Strokes | 3 | 70% | stroke styles, variable width, arrowhead previews in the menus | 3–5 |
| Effects | 4 | 70% | glass/texture fidelity, shadow spread on non-rects exactness | 4–7 |
| Frames, groups, sections, masks | 5 | 60% | section tooling, mask types (luminance/outline) | 5–8 |
| Frames, groups, sections, masks | 5 | 70% | section tooling (section presets, auto-size to contents, section-level properties) | 3–5 |
| Auto layout & constraints | 9 | 60% | grid/wrap-aware drag-reorder, drag to grid cell, nested hug/fill edge cases | 6–10 |
| Text | 8 | 55% | range fills in the Fill section, right-to-left/bidi, text style picker depth, axis hover preview | 10–16 |
| Components & variants | 9 | 55% | nested overrides depth, libraries, slots, descriptions | 12–18 |
+38
View File
@@ -113,6 +113,40 @@ fn tau() -> f64 {
std::f64::consts::TAU
}
/// How a mask layer masks the layers above it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum MaskType {
/// The mask's rendered alpha (soft edges, gradients, opacity).
#[default]
Alpha,
/// The mask's outline geometry, fully opaque (fills and opacity don't matter).
Vector,
/// The mask's brightness: white shows, black hides.
Luminance,
}
impl MaskType {
pub const ALL: [MaskType; 3] = [MaskType::Alpha, MaskType::Vector, MaskType::Luminance];
pub fn is_alpha(&self) -> bool {
*self == MaskType::Alpha
}
pub fn id(self) -> &'static str {
match self {
MaskType::Alpha => "alpha",
MaskType::Vector => "vector",
MaskType::Luminance => "luminance",
}
}
pub fn label(self) -> &'static str {
match self {
MaskType::Alpha => "Alpha",
MaskType::Vector => "Vector",
MaskType::Luminance => "Luminance",
}
}
}
/// A ruler guide on a page or frame.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Guide {
@@ -281,6 +315,9 @@ pub struct Node {
/// This node masks the siblings above it.
#[serde(default)]
pub is_mask: bool,
/// How a mask masks: by its alpha, its outline, or its luminance.
#[serde(default, skip_serializing_if = "MaskType::is_alpha")]
pub mask_type: MaskType,
#[serde(default)]
pub grids: Vec<LayoutGrid>,
#[serde(default)]
@@ -381,6 +418,7 @@ impl Node {
child_layout: ChildLayout::default(),
clip,
is_mask: false,
mask_type: MaskType::Alpha,
grids: Vec::new(),
exports: Vec::new(),
interactions: Vec::new(),
+4
View File
@@ -289,6 +289,10 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
"effects" => doc.get_mut(id)?.effects = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?,
"clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?,
"isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false),
"maskType" => {
let t = v.as_str().and_then(|s| MaskType::ALL.into_iter().find(|m| m.id().eq_ignore_ascii_case(s)));
doc.get_mut(id)?.mask_type = t.ok_or_else(|| bad(cmd, "maskType: alpha|vector|luminance"))?;
}
"layout" | "layoutMode" => {
let n = doc.get_mut(id)?;
if !n.kind.is_frame_like() {
+1
View File
@@ -103,6 +103,7 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
}
if n.is_mask {
o.insert("isMask".into(), json!(true));
o.insert("maskType".into(), json!(n.mask_type.id()));
}
if !n.grids.is_empty() {
o.insert("grids".into(), camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null)));
+2
View File
@@ -735,6 +735,8 @@ pub fn normalize(doc: &mut Document, before: &Document) {
#[cfg(test)]
mod frame_tests;
#[cfg(test)]
mod mask_tests;
#[cfg(test)]
mod style_tests;
#[cfg(test)]
mod tests;
+26
View File
@@ -0,0 +1,26 @@
//! Mask types through the session: the prop, inspection, undo, junk.
use serde_json::json;
use crate::Session;
use crate::params::id_of;
#[test]
fn mask_type_prop_inspects_and_undoes() {
let mut s = Session::new();
let m = id_of(&s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"]).unwrap();
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"]).unwrap();
s.execute("object.mask", &json!({"ids": [m, r]})).unwrap();
s.execute("node.set", &json!({"id": m, "props": {"maskType": "luminance"}})).unwrap();
assert_eq!(s.doc().unwrap().node(m).unwrap().mask_type, figcraft_doc::MaskType::Luminance);
let v = s.execute("node.get", &json!({"id": m})).unwrap();
assert_eq!(v["maskType"], json!("luminance"), "{v}");
s.undo().unwrap();
assert_eq!(s.doc().unwrap().node(m).unwrap().mask_type, figcraft_doc::MaskType::Alpha);
for bad in [json!("sparkly"), json!(3), json!(null)] {
assert!(s.execute("node.set", &json!({"id": m, "props": {"maskType": bad}})).is_err());
}
// Old documents (no field) load as alpha masks; alpha isn't written back out.
let json = serde_json::to_string(s.doc().unwrap().node(m).unwrap()).unwrap();
assert!(!json.contains("maskType"));
}
+14 -3
View File
@@ -565,12 +565,22 @@ fn draw_children(ctx: &Ctx, pix: &mut Pixmap, n: &Node, xf: Affine, depth: usize
fn draw_masked(ctx: &Ctx, pix: &mut Pixmap, mask_id: NodeId, content: &[NodeId], xf: Affine, depth: usize) {
let (w, h) = (pix.width(), pix.height());
let Some(mut mask_px) = Pixmap::new(w, h) else { return };
// Draw the mask node as if it weren't a mask.
let mask_type = ctx.doc.node(mask_id).map(|m| m.mask_type).unwrap_or_default();
// Draw the mask node as if it weren't a mask (a vector mask: its outline, fully opaque).
if let Some(m) = ctx.doc.node(mask_id) {
let mut m2 = m.clone();
m2.is_mask = false;
let mxf = xf * m2.transform;
if needs_layer(&m2) {
if mask_type == figcraft_doc::MaskType::Vector {
if let Some((outline, rule)) = subtree_outline(ctx.doc, &m2, depth + 1)
&& let Some(path) = ts_path(&outline)
{
let mut paint = tiny_skia::Paint::default();
paint.set_color_rgba8(255, 255, 255, 255);
paint.anti_alias = true;
mask_px.fill_path(&path, &paint, rule, ts_transform(mxf), None);
}
} else if needs_layer(&m2) {
let paint = PixmapPaint { opacity: m2.opacity, ..Default::default() };
let mut tmp = Pixmap::new(w, h);
if let Some(tmp) = tmp.as_mut() {
@@ -585,7 +595,8 @@ fn draw_masked(ctx: &Ctx, pix: &mut Pixmap, mask_id: NodeId, content: &[NodeId],
for c in content {
draw_node(ctx, &mut layer, *c, xf, depth + 1);
}
let mask = Mask::from_pixmap(mask_px.as_ref(), MaskType::Alpha);
let kind = if mask_type == figcraft_doc::MaskType::Luminance { MaskType::Luminance } else { MaskType::Alpha };
let mask = Mask::from_pixmap(mask_px.as_ref(), kind);
pix.draw_pixmap(0, 0, layer.as_ref(), &PixmapPaint::default(), Transform::identity(), Some(&mask));
}
+46
View File
@@ -141,6 +141,52 @@ fn mask_clips_siblings() {
assert_eq!(px(&pix, 80, 80)[0], 0xF5);
}
#[test]
fn luminance_and_vector_masks() {
let mut d = figcraft_doc::Document::new("t");
let p = d.first_page().unwrap();
let g = d.create(p, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap();
// The mask: white on the left half, black on the right.
let m = d.create(g, NodeKind::Rectangle, "M", 0.0, 0.0, 100.0, 100.0).unwrap();
let grad = figcraft_color::Gradient {
kind: figcraft_color::GradientKind::Linear,
stops: vec![
figcraft_color::ColorStop { position: 0.0, color: Color::WHITE },
figcraft_color::ColorStop { position: 0.49, color: Color::WHITE },
figcraft_color::ColorStop { position: 0.51, color: Color::BLACK },
figcraft_color::ColorStop { position: 1.0, color: Color::BLACK },
],
from: (0.0, 0.5),
to: (1.0, 0.5),
width: (0.0, 1.0),
};
d.get_mut(m).unwrap().fills = vec![Paint::gradient(grad)];
d.get_mut(m).unwrap().is_mask = true;
let r = d.create(g, NodeKind::Rectangle, "R", 0.0, 0.0, 100.0, 100.0).unwrap();
d.get_mut(r).unwrap().fills = vec![Paint::solid(Color::BLACK)];
// Alpha: the mask is opaque everywhere, so everything shows.
let pix = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
assert_eq!(px(&pix, 80, 50), [0, 0, 0, 255]);
// Luminance: only the white half shows.
d.get_mut(m).unwrap().mask_type = figcraft_doc::MaskType::Luminance;
let pix = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
assert_eq!(px(&pix, 20, 50), [0, 0, 0, 255]);
assert_eq!(px(&pix, 80, 50)[0], 0xF5, "black hides");
// Vector: a fully transparent ellipse still masks by its outline.
let e = d.create(g, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "E", 0.0, 0.0, 50.0, 50.0).unwrap();
d.get_mut(e).unwrap().fills = vec![Paint::solid(Color::new(0.0, 0.0, 0.0, 0.0))];
d.get_mut(e).unwrap().is_mask = true;
d.get_mut(e).unwrap().mask_type = figcraft_doc::MaskType::Vector;
d.get_mut(g).unwrap().children = vec![e, r];
let pix = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
assert_eq!(px(&pix, 25, 25), [0, 0, 0, 255], "inside the outline");
assert_eq!(px(&pix, 80, 80)[0], 0xF5, "outside");
// As an alpha mask the transparent ellipse hides everything.
d.get_mut(e).unwrap().mask_type = figcraft_doc::MaskType::Alpha;
let pix = render_view(&d, p, &view(100, 100), &Options::default()).unwrap();
assert_eq!(px(&pix, 25, 25)[0], 0xF5);
}
#[test]
fn boolean_subtract_renders_hole() {
let mut d = figcraft_doc::Document::new("t");
+16
View File
@@ -511,6 +511,22 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
ui.data_mut(|d| d.insert_temp(Id::new("blend_open"), !d.get_temp::<bool>(Id::new("blend_open")).unwrap_or(false)));
}
let col = widgets::col_width(ui.available_width());
// Masks: how they mask.
if sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.is_mask)) {
let mt = common(doc, sel, |n| n.mask_type);
widgets::sublabels(ui, "Mask", None);
row(ui, 32.0, |ui| {
let resp = widgets::dropdown(ui, mt.map(MaskType::label).unwrap_or("Mixed"), col * 2.0 + 8.0, Some(Icon::Mask));
egui::Popup::menu(&resp).show(|ui| {
for m in MaskType::ALL {
if menu_item(ui, m.label(), "") {
app.set_props(&ids, json!({"maskType": m.id()}));
ui.close();
}
}
});
});
}
let op = common(doc, sel, |n| (f64::from(n.opacity) * 100.0).round());
let radius_ok = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.has_corner_radius()));
let r = common(doc, sel, |n| n.corners.uniform().map(|r| (r * 100.0).round() / 100.0));
+1 -1
View File
@@ -98,7 +98,7 @@ Or in a project's `.mcp.json`:
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `inspect.code` (CSS, SwiftUI or Jetpack Compose for a layer), `style.create`, `variable.bind`, `proto.interaction.add`). |
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing. |
| `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. |
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, text…); returns the updated layers. |
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), text…); returns the updated layers. |
| `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). |
| `get_node {id, depth}` | One layer as JSON. |
| `select {ids}` | Replace the selection (`[]` deselects). |