From 840d3ac5d2a89677e32ce0ef24239598aaf90398 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 03:45:45 +0700 Subject: [PATCH] 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 --- ROADMAP.md | 2 +- crates/doc/src/node.rs | 38 +++++++++++++++++++++++++++ crates/engine/src/cmd/props.rs | 4 +++ crates/engine/src/inspect.rs | 1 + crates/engine/src/lib.rs | 2 ++ crates/engine/src/mask_tests.rs | 26 +++++++++++++++++++ crates/render/src/draw.rs | 17 +++++++++--- crates/render/src/tests.rs | 46 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/right.rs | 16 ++++++++++++ docs/mcp.md | 2 +- 10 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/mask_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index b0e6b2b6..d5b8c8f2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 2bba663a..4ed458ba 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -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, #[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(), diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 239f7379..facd4ead 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -289,6 +289,10 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map 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() { diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 4507ee20..3701227b 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -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))); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 72717aa3..de3eae3c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/mask_tests.rs b/crates/engine/src/mask_tests.rs new file mode 100644 index 00000000..ece8019f --- /dev/null +++ b/crates/engine/src/mask_tests.rs @@ -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")); +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 689c9ab0..21be55ae 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -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)); } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 3b293362..7e67b4ea 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -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"); diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index c99deb30..5a071593 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -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::(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)); diff --git a/docs/mcp.md b/docs/mcp.md index 16ee14ff..6385c0c9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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). |