M3.84: Object > Expand dialog; gradient fills expand to a mesh or N objects
Object > Expand… opens a purpose-built dialog (Object, Fill and Stroke
checkboxes, disabled when the selection has nothing for them; Expand
Gradient To: Gradient Mesh or Specify N Objects, default 255). Every
option now does something:
- Object: type to outlines, live shapes to paths, effects baked.
- Stroke: strokes outlined into filled paths.
- Fill: linear/radial gradient fills become N solid rectangles or
concentric ellipses, or a gradient mesh with lines where the colour
changes, inside a clip group shaped like the object; freeform
gradients become a mesh shaped like the object. Colours keep their
stops' colour model. With Fill off gradient fills stay live.
object.expand {} defaults match the dialog (stroke now defaults on);
new query object.expand.info and UI command ui.expandDialog.
Create Gradient Mesh on a gradient-filled object takes the colours the
gradient paints at each point (GradientMesh::for_path_with, from_grid,
highlight).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4757dd265c
commit
705976fd96
|
|
@ -87,6 +87,11 @@ impl Default for Gradient {
|
|||
impl Gradient {
|
||||
/// Colour and opacity at `t` (honours midpoints).
|
||||
pub fn sample(&self, t: f32) -> (Color, f32) {
|
||||
self.sample_with(t, Color::lerp)
|
||||
}
|
||||
/// [`Gradient::sample`] with neighbouring stop colours mixed by `mix` (e.g. in their own colour
|
||||
/// model rather than display RGB).
|
||||
pub fn sample_with(&self, t: f32, mix: impl Fn(&Color, &Color, f32) -> Color) -> (Color, f32) {
|
||||
let stops = &self.stops;
|
||||
if stops.is_empty() {
|
||||
return (Color::BLACK, 1.0);
|
||||
|
|
@ -102,7 +107,7 @@ impl Gradient {
|
|||
// Map through the midpoint: u=mid → 0.5.
|
||||
let m = a.midpoint.clamp(0.01, 0.99);
|
||||
let v = if u < m { 0.5 * u / m } else { 0.5 + 0.5 * (u - m) / (1.0 - m) };
|
||||
return (a.color.lerp(&b.color, v), a.opacity + (b.opacity - a.opacity) * v);
|
||||
return (mix(&a.color, &b.color, v), a.opacity + (b.opacity - a.opacity) * v);
|
||||
}
|
||||
}
|
||||
let l = stops.last().unwrap();
|
||||
|
|
|
|||
|
|
@ -812,6 +812,12 @@ impl Coons {
|
|||
// Gradient meshes
|
||||
// =====================================================================================
|
||||
|
||||
/// `0, 1/n, …, 1` for `n` (clamped to 1..=200) even steps.
|
||||
fn even_steps(n: u32) -> Vec<f64> {
|
||||
let n = n.clamp(1, 200);
|
||||
(0..=n).map(|i| i as f64 / n as f64).collect()
|
||||
}
|
||||
|
||||
impl GradientMesh {
|
||||
pub fn idx(&self, r: usize, c: usize) -> usize {
|
||||
r * (self.cols as usize + 1) + c
|
||||
|
|
@ -822,43 +828,73 @@ impl GradientMesh {
|
|||
|
||||
/// A mesh over surface `s` (u, v ∈ [0,1]) with handles from its partial derivatives.
|
||||
pub fn from_surface(rows: u32, cols: u32, s: &dyn Fn(f64, f64) -> Point, color: &dyn Fn(f64, f64) -> Color) -> Self {
|
||||
let (rows, cols) = (rows.clamp(1, 200), cols.clamp(1, 200));
|
||||
let (du, dv) = (1.0 / cols as f64, 1.0 / rows as f64);
|
||||
let (us, vs) = (even_steps(cols), even_steps(rows));
|
||||
Self::from_grid(&us, &vs, s, &|r, c, _| (color(us[c], vs[r]), 1.0))
|
||||
}
|
||||
|
||||
/// A mesh over surface `s` (u, v ∈ [0,1]) whose column lines sit at `us` and row lines at `vs`
|
||||
/// (ascending, at least two each; equal neighbours make a patch of no width, a sharp colour
|
||||
/// change), with handles from its partial derivatives. `color(row, col, point)` gives each
|
||||
/// point its colour and opacity.
|
||||
pub fn from_grid(us: &[f64], vs: &[f64], s: &dyn Fn(f64, f64) -> Point, color: &dyn Fn(usize, usize, Point) -> (Color, f32)) -> Self {
|
||||
let (rows, cols) = (vs.len().saturating_sub(1), us.len().saturating_sub(1));
|
||||
let eps = 1e-3;
|
||||
let mut points = Vec::with_capacity(((rows + 1) * (cols + 1)) as usize);
|
||||
for r in 0..=rows {
|
||||
for c in 0..=cols {
|
||||
let (u, v) = (c as f64 * du, r as f64 * dv);
|
||||
let mut points = Vec::with_capacity(us.len() * vs.len());
|
||||
for (r, &v) in vs.iter().enumerate() {
|
||||
for (c, &u) in us.iter().enumerate() {
|
||||
let p = s(u, v);
|
||||
let d_u = (s((u + eps).min(1.0), v) - s((u - eps).max(0.0), v)) / ((u + eps).min(1.0) - (u - eps).max(0.0));
|
||||
let d_v = (s(u, (v + eps).min(1.0)) - s(u, (v - eps).max(0.0))) / ((v + eps).min(1.0) - (v - eps).max(0.0));
|
||||
let hu = d_u * (du / 3.0);
|
||||
let hv = d_v * (dv / 3.0);
|
||||
// Outward handles on the border are unused: keep them at the point.
|
||||
let z = Vec2::ZERO;
|
||||
let handles =
|
||||
[if c < cols { hu } else { z }, if c > 0 { -hu } else { z }, if r < rows { hv } else { z }, if r > 0 { -hv } else { z }];
|
||||
points.push(MeshPoint { p, color: color(u, v), opacity: 1.0, handles });
|
||||
// A third of the way to the neighbouring line along the tangent; outward handles on
|
||||
// the border are unused, so they stay at the point.
|
||||
let h = |d: Vec2, from: f64, to: Option<&f64>| to.map_or(Vec2::ZERO, |to| d * ((to - from) / 3.0));
|
||||
let handles = [
|
||||
h(d_u, u, us.get(c + 1)),
|
||||
h(d_u, u, c.checked_sub(1).map(|i| &us[i])),
|
||||
h(d_v, v, vs.get(r + 1)),
|
||||
h(d_v, v, r.checked_sub(1).map(|i| &vs[i])),
|
||||
];
|
||||
let (color, opacity) = color(r, c, p);
|
||||
points.push(MeshPoint { p, color, opacity, handles });
|
||||
}
|
||||
}
|
||||
Self { rows, cols, points }
|
||||
Self { rows: rows as u32, cols: cols as u32, points }
|
||||
}
|
||||
|
||||
/// Create Gradient Mesh for a path: the outline split at its corners, coloured per `appearance`.
|
||||
pub fn for_path(path: &PathData, rows: u32, cols: u32, base: Color, appearance: MeshAppearance, highlight: f64) -> Option<Self> {
|
||||
let mut m = Self::for_path_with(path, rows, cols, &|_| (base, 1.0))?;
|
||||
m.highlight(appearance, highlight);
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// Create Gradient Mesh for a path: the outline split at its corners, each point taking the
|
||||
/// colour and opacity `color_at` gives at its position (a gradient fill's, say).
|
||||
pub fn for_path_with(path: &PathData, rows: u32, cols: u32, color_at: &dyn Fn(Point) -> (Color, f32)) -> Option<Self> {
|
||||
let coons = Coons::from_path(path)?;
|
||||
Some(Self::from_grid(&even_steps(cols), &even_steps(rows), &|u, v| coons.eval(u, v), &|_, _, p| color_at(p)))
|
||||
}
|
||||
|
||||
/// Create Gradient Mesh → Appearance: lighten the points towards white, to the centre or to
|
||||
/// the edges, by at most `highlight` %.
|
||||
pub fn highlight(&mut self, appearance: MeshAppearance, highlight: f64) {
|
||||
let h = (highlight / 100.0).clamp(0.0, 1.0) as f32;
|
||||
let color = |u: f64, v: f64| -> Color {
|
||||
let d = ((u - 0.5).abs().max((v - 0.5).abs()) * 2.0) as f32; // 0 centre … 1 edge
|
||||
let w = match appearance {
|
||||
MeshAppearance::Flat => 0.0,
|
||||
MeshAppearance::ToCenter => (1.0 - d) * h,
|
||||
MeshAppearance::ToEdge => d * h,
|
||||
};
|
||||
if w > 0.0 { lerp_color(&base, &Color::WHITE, w) } else { base }
|
||||
};
|
||||
let s = |u: f64, v: f64| coons.eval(u, v);
|
||||
Some(Self::from_surface(rows, cols, &s, &color))
|
||||
let (rows, cols) = (self.rows as usize, self.cols as usize);
|
||||
for r in 0..=rows {
|
||||
for c in 0..=cols {
|
||||
let (u, v) = (c as f64 / cols as f64, r as f64 / rows as f64);
|
||||
let d = ((u - 0.5).abs().max((v - 0.5).abs()) * 2.0) as f32; // 0 centre … 1 edge
|
||||
let w = match appearance {
|
||||
MeshAppearance::Flat => 0.0,
|
||||
MeshAppearance::ToCenter => (1.0 - d) * h,
|
||||
MeshAppearance::ToEdge => d * h,
|
||||
};
|
||||
if w > 0.0 {
|
||||
let i = self.idx(r, c);
|
||||
self.points[i].color = lerp_color(&self.points[i].color, &Color::WHITE, w);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizontal edge from (r, c) to (r, c+1).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,408 @@
|
|||
//! Object → Expand: live shapes, type and effects (Object), strokes (Stroke) and gradient fills
|
||||
//! (Fill) into plain art in one undo step. A linear or radial gradient fill becomes a gradient mesh,
|
||||
//! or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform
|
||||
//! gradient becomes a mesh shaped like the object. Create Gradient Mesh shares the sampling.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::cms::Model;
|
||||
use vectorcraft_color::freeform::spread_scale;
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint};
|
||||
use vectorcraft_doc::appearance::{AppearanceItem, FillLayer};
|
||||
use vectorcraft_doc::live::{GradientMesh, lerp_color};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath, Vec2, shapes};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::menucmds::{run_raw, squash};
|
||||
use super::object::as_clipping_path;
|
||||
use super::pathops::{assemble, node_path, replace_leaves, styled_copy, with_transparency};
|
||||
use super::*;
|
||||
|
||||
const C: &str = "object.expand";
|
||||
/// Expand Gradient To → Specify … Objects: the default and the most.
|
||||
pub const DEFAULT_STEPS: u32 = 255;
|
||||
pub const MAX_STEPS: u32 = 1000;
|
||||
/// Rows and columns of the mesh a freeform gradient expands to.
|
||||
const FREEFORM_MESH: u32 = 8;
|
||||
/// Columns (around the centre) of the mesh a radial gradient expands to.
|
||||
const RADIAL_MESH_COLS: usize = 8;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"object.expand",
|
||||
"Expand…",
|
||||
["Object"],
|
||||
None,
|
||||
"{object?: true (type → outlines, live shapes → paths, effects baked), fill?: true (gradient fills → `gradient` art), stroke?: true (strokes → filled outlines), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh), both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step → {ids}",
|
||||
has_selection,
|
||||
expand
|
||||
),
|
||||
cmd!(
|
||||
query "object.expand.info",
|
||||
"Expand Options",
|
||||
[],
|
||||
None,
|
||||
"{} what each Expand option would change in the selection → {object, fill, stroke} (true: something to expand)",
|
||||
has_selection,
|
||||
|s, _| {
|
||||
let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
|
||||
Ok(json!({"object": e.object(), "fill": e.fill, "stroke": e.stroke}))
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Expand Gradient To.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum GradientTo {
|
||||
Mesh,
|
||||
Objects(u32),
|
||||
}
|
||||
|
||||
impl GradientTo {
|
||||
fn parse(p: &Value) -> Result<Self> {
|
||||
let steps = f64_or(p, "steps", DEFAULT_STEPS as f64).round();
|
||||
if !(1.0..=MAX_STEPS as f64).contains(&steps) {
|
||||
return Err(bad(C, format!("steps must be 1..{MAX_STEPS}")));
|
||||
}
|
||||
match str_param(p, "gradient").unwrap_or("objects") {
|
||||
"objects" => Ok(Self::Objects(steps as u32)),
|
||||
"mesh" => Ok(Self::Mesh),
|
||||
_ => Err(bad(C, "gradient must be \"objects\" or \"mesh\"")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the selection holds for each Expand option.
|
||||
#[derive(Default)]
|
||||
struct Expandable {
|
||||
text: bool,
|
||||
live: bool,
|
||||
effects: bool,
|
||||
fill: bool,
|
||||
stroke: bool,
|
||||
}
|
||||
|
||||
impl Expandable {
|
||||
fn of(d: &Document, roots: &[NodeId]) -> Self {
|
||||
let mut e = Self::default();
|
||||
for n in roots.iter().filter_map(|r| d.node(*r)) {
|
||||
n.walk(&mut |c| {
|
||||
let shape = matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. });
|
||||
e.text |= matches!(c.kind, NodeKind::Text(_));
|
||||
e.live |= matches!(c.kind, NodeKind::Path { live: Some(_), .. });
|
||||
e.effects |= !c.appearance.effects.is_empty();
|
||||
e.stroke |= shape && !c.appearance.stroke_paint().is_none();
|
||||
e.fill |= match &c.kind {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
|
||||
_ => shape && c.appearance.items.iter().any(|i| gradient_fill(i).is_some()),
|
||||
};
|
||||
});
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
fn object(&self) -> bool {
|
||||
self.text || self.live || self.effects
|
||||
}
|
||||
}
|
||||
|
||||
/// The gradient of a visible gradient fill.
|
||||
fn gradient_fill(i: &AppearanceItem) -> Option<(&FillLayer, &GradientPaint)> {
|
||||
match i {
|
||||
AppearanceItem::Fill(f) if f.visible => match &f.paint {
|
||||
Paint::Gradient(g) => Some((f, g)),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let object = bool_or(p, "object", true);
|
||||
let fill = bool_or(p, "fill", true);
|
||||
let stroke = bool_or(p, "stroke", true);
|
||||
let to = GradientTo::parse(p)?;
|
||||
let from = s.doc()?.history.undo.len();
|
||||
let rev0 = s.doc()?.revision;
|
||||
let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
|
||||
if object && e.effects {
|
||||
let _ = run_raw(s, "effect.expandAppearance", &json!({}));
|
||||
}
|
||||
if object && e.live {
|
||||
let roots = selected_roots(s)?;
|
||||
s.edit("Expand", |d, _| {
|
||||
for r in &roots {
|
||||
if let Some(n) = d.node_mut(*r) {
|
||||
drop_live(n);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
if object && e.text {
|
||||
let _ = run_raw(s, "type.createOutlines", &json!({}));
|
||||
}
|
||||
if stroke && e.stroke {
|
||||
let _ = run_raw(s, "object.path.outlineStroke", &json!({}));
|
||||
}
|
||||
if fill {
|
||||
let _ = expand_gradient_fills(s, to);
|
||||
}
|
||||
if s.doc()?.revision == rev0 {
|
||||
return Err(EngineError::Other("Expand: nothing to expand".into()));
|
||||
}
|
||||
squash(s, from, "Expand");
|
||||
Ok(json!({ "ids": s.doc()?.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn drop_live(n: &mut Node) {
|
||||
if let NodeKind::Path { live, .. } = &mut n.kind {
|
||||
*live = None;
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
for c in ch.iter_mut() {
|
||||
drop_live(Arc::make_mut(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace every gradient fill of the selected paths by art (see [`expand_fills`]).
|
||||
fn expand_gradient_fills(s: &mut Session, to: GradientTo) -> Result<()> {
|
||||
let roots = selected_roots(s)?;
|
||||
s.edit("Expand", |d, sel| {
|
||||
let (mut ids, mut changed) = (vec![], 0);
|
||||
for r in &roots {
|
||||
let (id, n) = replace_leaves(d, *r, |d, l| expand_fills(d, l, to))?;
|
||||
ids.push(id);
|
||||
changed += n;
|
||||
}
|
||||
if changed == 0 {
|
||||
return Err(EngineError::Other("Expand: no gradient fills".into()));
|
||||
}
|
||||
sel.set(ids);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Path `l` with each gradient fill turned into art, in appearance order: the other items stay
|
||||
/// on plain copies of the path. `None` when it has no gradient fill.
|
||||
fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
|
||||
if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || !l.appearance.items.iter().any(|i| gradient_fill(i).is_some()) {
|
||||
return None;
|
||||
}
|
||||
let (path, _) = node_path(l)?;
|
||||
let bounds = path.bounds()?;
|
||||
let mut parts = vec![];
|
||||
let mut rest = vec![];
|
||||
for item in &l.appearance.items {
|
||||
// Items that paint nothing go; a gradient that can't be expanded stays.
|
||||
let Some(art) = gradient_fill(item).and_then(|(f, g)| gradient_art(d, l, &path, bounds, f, g, to)) else {
|
||||
if item.visible() && !item.paint().is_none() {
|
||||
rest.push(item.clone());
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if !rest.is_empty() {
|
||||
parts.push(styled_copy(d, l, std::mem::take(&mut rest)));
|
||||
}
|
||||
parts.push(art);
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
parts.push(styled_copy(d, l, rest));
|
||||
}
|
||||
assemble(d, l, parts)
|
||||
}
|
||||
|
||||
/// Gradient fill `f` (gradient `g`) of path `l` (outline `path`, box `bounds`) as art, with the
|
||||
/// fill's opacity, blend mode and effects.
|
||||
fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &FillLayer, g: &GradientPaint, to: GradientTo) -> Option<Node> {
|
||||
let content = if g.gradient.kind == GradientKind::Freeform {
|
||||
let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds))?;
|
||||
vec![Node::new(d.alloc_id(), NodeKind::Mesh(m))]
|
||||
} else {
|
||||
let frame = Frame::new(g, bounds);
|
||||
match to {
|
||||
GradientTo::Mesh => vec![Node::new(d.alloc_id(), NodeKind::Mesh(frame.mesh(&g.gradient)))],
|
||||
GradientTo::Objects(n) => frame
|
||||
.pieces(&g.gradient, n as usize)
|
||||
.into_iter()
|
||||
.map(|(shape, (color, opacity))| {
|
||||
let fill = FillLayer { opacity, overprint: f.overprint, ..FillLayer::new(Paint::solid(color)) };
|
||||
Node::path(d.alloc_id(), shape, Appearance { items: vec![AppearanceItem::Fill(fill)], ..Default::default() })
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
};
|
||||
// A freeform mesh takes the object's outline: only holes need the object to clip it.
|
||||
let art = if g.gradient.kind == GradientKind::Freeform && path.subpaths.len() == 1 {
|
||||
content.into_iter().next()?
|
||||
} else {
|
||||
let mut clip = styled_copy(d, l, vec![]);
|
||||
as_clipping_path(&mut clip).ok()?;
|
||||
let children = std::iter::once(clip).chain(content).map(Arc::new).collect();
|
||||
Node::new(d.alloc_id(), NodeKind::Group { children, clip: true })
|
||||
};
|
||||
let mut art = with_transparency(d, art, f.opacity, f.blend);
|
||||
art.appearance.effects = f.effects.clone();
|
||||
Some(art)
|
||||
}
|
||||
|
||||
/// The colour and opacity gradient fill `g` paints at a document point of an object whose box is
|
||||
/// `bounds`, as the canvas draws it (the end colours continue past the ends). Colours stay in the
|
||||
/// colour model their stops (or freeform points) share; display RGB where models differ.
|
||||
pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect) -> Box<dyn Fn(Point) -> (Color, f32) + '_> {
|
||||
if g.gradient.kind == GradientKind::Freeform {
|
||||
let b = bounds.abs();
|
||||
let f = g.freeform_on(b);
|
||||
let model = shared_model(f.points.iter().map(|p| p.color));
|
||||
let field = f.field(spread_scale(b));
|
||||
return Box::new(move |p| {
|
||||
let ([r, g, b], a) = field.sample(p);
|
||||
let c = Color::rgb(r, g, b);
|
||||
(model.map_or(c, |m| c.in_model(m)), a)
|
||||
});
|
||||
}
|
||||
let geom = g.resolve(bounds);
|
||||
Box::new(move |p| g.gradient.sample_with(geom.param_at(g.gradient.kind, p) as f32, lerp_color))
|
||||
}
|
||||
|
||||
/// The colour model all of `colors` share.
|
||||
fn shared_model(mut colors: impl Iterator<Item = Color>) -> Option<Model> {
|
||||
let m = colors.next()?.model();
|
||||
colors.all(|c| c.model() == m).then_some(m)
|
||||
}
|
||||
|
||||
/// Where the colour changes along a gradient between parameters `t0` and `t1`: both ends, every
|
||||
/// stop between them (in its own colour, so coincident stops keep a sharp edge) and every midpoint
|
||||
/// off the centre, as (parameter, colour, opacity) in order, with more between them where they
|
||||
/// are over `max_gap` apart. The colour is linear in display RGB between neighbours, as the
|
||||
/// canvas draws it.
|
||||
fn breaks(g: &Gradient, t0: f64, t1: f64, max_gap: f64) -> Vec<(f64, (Color, f32))> {
|
||||
let at = |t: f64| (t, g.sample_with(t as f32, lerp_color));
|
||||
let mut v = vec![at(t0)];
|
||||
for (i, s) in g.stops.iter().enumerate() {
|
||||
let o = s.offset as f64;
|
||||
if o > t0 && o < t1 {
|
||||
v.push((o, (s.color, s.opacity)));
|
||||
}
|
||||
if let Some(n) = g.stops.get(i + 1)
|
||||
&& (s.midpoint - 0.5).abs() > 1e-3
|
||||
{
|
||||
let m = (s.offset + (n.offset - s.offset) * s.midpoint) as f64;
|
||||
if m > t0 && m < t1 {
|
||||
v.push(at(m));
|
||||
}
|
||||
}
|
||||
}
|
||||
v.push(at(t1));
|
||||
v.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut out = Vec::with_capacity(v.len());
|
||||
for w in v.windows(2) {
|
||||
out.push(w[0]);
|
||||
let (a, b) = (w[0].0, w[1].0);
|
||||
let n = ((b - a) / max_gap).ceil() as usize;
|
||||
out.extend((1..n).map(|k| at(a + (b - a) * k as f64 / n as f64)));
|
||||
}
|
||||
out.extend(v.last().copied());
|
||||
out
|
||||
}
|
||||
|
||||
/// A linear or radial gradient's geometry over an object's box: its start, the vector to its
|
||||
/// end, the vector across it (radial: the ellipse's other axis), and the parameter ranges that
|
||||
/// cover the box (linear: along the vector `t0..t1` and across it `s0..s1` in points; radial:
|
||||
/// `0..t1`).
|
||||
struct Frame {
|
||||
radial: bool,
|
||||
start: Point,
|
||||
along: Vec2,
|
||||
across: Vec2,
|
||||
t0: f64,
|
||||
t1: f64,
|
||||
s0: f64,
|
||||
s1: f64,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
fn new(g: &GradientPaint, bounds: Rect) -> Self {
|
||||
let geom = g.resolve(bounds);
|
||||
let radial = g.gradient.kind == GradientKind::Radial;
|
||||
// A vector of no length paints as one pointing right (as the canvas does).
|
||||
let along = if geom.start.distance(geom.end) < 1e-9 { Vec2::new(1.0, 0.0) } else { geom.end - geom.start };
|
||||
let normal = Vec2::new(-along.y, along.x) / along.hypot();
|
||||
let across = if radial { normal * along.hypot() * geom.aspect } else { normal };
|
||||
let corners = [bounds.origin(), Point::new(bounds.x1, bounds.y0), Point::new(bounds.x1, bounds.y1), Point::new(bounds.x0, bounds.y1)];
|
||||
let range = |f: &dyn Fn(Point) -> f64| corners.iter().map(|c| f(*c)).fold((f64::MAX, f64::MIN), |(lo, hi), x| (lo.min(x), hi.max(x)));
|
||||
let (mut t0, mut t1) = range(&|c| (c - geom.start).dot(along) / along.hypot2());
|
||||
let (s0, s1) = range(&|c| (c - geom.start).dot(normal));
|
||||
if radial {
|
||||
let geom = vectorcraft_color::GradientGeom { end: geom.start + along, ..geom };
|
||||
(t0, t1) = (0.0, range(&|c| geom.param_at(GradientKind::Radial, c)).1);
|
||||
}
|
||||
// Keep a sliver of span so the art maps back invertibly.
|
||||
t1 = t1.max(t0 + 1e-6);
|
||||
Self { radial, start: geom.start, along, across, t0, t1, s0, s1 }
|
||||
}
|
||||
|
||||
/// Linear: the point at parameter `t` along and `s` across. Radial: the point at parameter `t`
|
||||
/// and angle `s` (radians).
|
||||
fn at(&self, t: f64, s: f64) -> Point {
|
||||
if self.radial { self.start + (self.along * s.cos() + self.across * s.sin()) * t } else { self.start + self.along * t + self.across * s }
|
||||
}
|
||||
|
||||
/// A gradient mesh covering the box: columns (linear) or rings (radial) where the colour
|
||||
/// changes, so it paints the gradient exactly.
|
||||
fn mesh(&self, g: &Gradient) -> GradientMesh {
|
||||
// Rings as close as the columns around them, so the mesh draws smoothly.
|
||||
let gap = if self.radial { (self.t1 - self.t0) / RADIAL_MESH_COLS as f64 } else { f64::INFINITY };
|
||||
let br = breaks(g, self.t0, self.t1, gap);
|
||||
let ts: Vec<f64> = br.iter().map(|(t, _)| (t - self.t0) / (self.t1 - self.t0)).collect();
|
||||
let t = |x: f64| self.t0 + x * (self.t1 - self.t0);
|
||||
if self.radial {
|
||||
let angles: Vec<f64> = (0..=RADIAL_MESH_COLS).map(|i| i as f64 / RADIAL_MESH_COLS as f64).collect();
|
||||
GradientMesh::from_grid(&angles, &ts, &|u, v| self.at(t(v), u * std::f64::consts::TAU), &|r, _, _| br[r].1)
|
||||
} else {
|
||||
GradientMesh::from_grid(&ts, &[0.0, 1.0], &|u, v| self.at(t(u), self.s0 + v * (self.s1 - self.s0)), &|_, c, _| br[c].1)
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` solid shapes covering the box in paint order, each coloured as the gradient in the
|
||||
/// middle of its band: for a linear gradient, rectangles from each band's start to the far
|
||||
/// end, for a radial one, concentric ellipses (largest first). Each paints over the ones
|
||||
/// below except its band, so no seam shows between bands. With translucent stops each shape
|
||||
/// is just its band, so nothing doubles up.
|
||||
fn pieces(&self, g: &Gradient, n: usize) -> Vec<(PathData, (Color, f32))> {
|
||||
let step = (self.t1 - self.t0) / n as f64;
|
||||
let color = |k: usize| g.sample_with((self.t0 + (k as f64 + 0.5) * step) as f32, lerp_color);
|
||||
let opaque = g.stops.iter().all(|s| s.opacity >= 1.0);
|
||||
if self.radial {
|
||||
let ellipse = |t: f64| {
|
||||
let m = Affine::new([self.along.x * t, self.along.y * t, self.across.x * t, self.across.y * t, self.start.x, self.start.y]);
|
||||
shapes::ellipse(Rect::new(-1.0, -1.0, 1.0, 1.0)).transformed(m)
|
||||
};
|
||||
return (0..n)
|
||||
.rev()
|
||||
.map(|k| {
|
||||
let mut shape = ellipse((k + 1) as f64 * step);
|
||||
if !opaque && k > 0 {
|
||||
let mut hole = ellipse(k as f64 * step).subpaths.remove(0);
|
||||
hole.reverse();
|
||||
shape.subpaths.push(hole);
|
||||
}
|
||||
(shape, color(k))
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
(0..n)
|
||||
.map(|k| {
|
||||
let a = self.t0 + k as f64 * step;
|
||||
let b = if opaque { self.t1 } else { a + step };
|
||||
let pts = [self.at(a, self.s0), self.at(b, self.s0), self.at(b, self.s1), self.at(a, self.s1)];
|
||||
(PathData::single(SubPath::polyline(&pts, true)), color(k))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection};
|
|||
use vectorcraft_geom::{Affine, PathData, Point};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::expand::gradient_sampler;
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
|
@ -161,7 +162,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Create Gradient Mesh…",
|
||||
["Object"],
|
||||
None,
|
||||
"{ids?, rows?: n (4), cols?: n (4), appearance?: flat|center|edge, highlight?: % (100), at?: [x,y]} convert filled paths into gradient meshes (with `at`: 1×1 mesh plus lines through that point) → {ids, index?}",
|
||||
"{ids?, rows?: n (4), cols?: n (4), appearance?: flat|center|edge, highlight?: % (100), at?: [x,y]} convert filled paths into gradient meshes in their fill colour (a gradient fill: the colour it paints at each point), lightened per `appearance` (with `at`: 1×1 mesh plus lines through that point) → {ids, index?}",
|
||||
has_selection,
|
||||
mesh_create
|
||||
),
|
||||
|
|
@ -723,12 +724,17 @@ fn mesh_create(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
for id in &roots {
|
||||
let Some(n) = d.node(*id).cloned() else { continue };
|
||||
let Some(path) = outline_of(&n) else { continue };
|
||||
let base = match n.appearance.fill_paint() {
|
||||
Paint::Solid { color, .. } => color,
|
||||
Paint::Gradient(g) => g.gradient.sample(0.5).0,
|
||||
_ => Color::WHITE,
|
||||
let m = match n.appearance.fill_paint() {
|
||||
// The points take the colours the gradient paints there.
|
||||
Paint::Gradient(g) => {
|
||||
path.bounds().and_then(|b| GradientMesh::for_path_with(&path, rows, cols, &*gradient_sampler(&g, b))).map(|mut m| {
|
||||
m.highlight(app, highlight);
|
||||
m
|
||||
})
|
||||
}
|
||||
p => GradientMesh::for_path(&path, rows, cols, p.color().unwrap_or(Color::WHITE), app, highlight),
|
||||
};
|
||||
let Some(mut m) = GradientMesh::for_path(&path, rows, cols, base, app, highlight) else { continue };
|
||||
let Some(mut m) = m else { continue };
|
||||
if let Some(q) = at {
|
||||
index = m.add_lines_at(q);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! Object menu long tail: Lock/Hide All Artwork Above & Other Layers, Transform Each, Reset
|
||||
//! Bounding Box, Expand, Rasterize, Crop Image, Create Trim Marks, Convert to Shape and
|
||||
//! Bounding Box, Rasterize, Crop Image, Create Trim Marks, Convert to Shape and
|
||||
//! Artboards → Convert / Rearrange. (Live blends are in `live.rs`.)
|
||||
|
||||
use std::sync::Arc;
|
||||
|
|
@ -83,15 +83,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_selection,
|
||||
|_, _| Ok(json!({ "changed": 0 }))
|
||||
),
|
||||
cmd!(
|
||||
"object.expand",
|
||||
"Expand…",
|
||||
["Object"],
|
||||
None,
|
||||
"{object?: true (text → outlines, live shapes → paths, effects baked), fill?: true, stroke?: false (strokes → filled outlines)} one undo step → {ids}",
|
||||
has_selection,
|
||||
expand
|
||||
),
|
||||
cmd!(
|
||||
"object.rasterize",
|
||||
"Rasterize…",
|
||||
|
|
@ -299,69 +290,6 @@ fn transform_each(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(ids_json(&ids))
|
||||
}
|
||||
|
||||
// ---------- Expand ----------
|
||||
|
||||
fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let object = bool_or(p, "object", true);
|
||||
let fill = bool_or(p, "fill", true);
|
||||
let stroke = bool_or(p, "stroke", false);
|
||||
let from = s.doc()?.history.undo.len();
|
||||
let rev0 = s.doc()?.revision;
|
||||
let roots = selected_roots(s)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let (mut has_text, mut has_fx, mut has_live, mut has_stroke) = (false, false, false, false);
|
||||
for r in &roots {
|
||||
if let Some(n) = d.node(*r) {
|
||||
n.walk(&mut |c| {
|
||||
has_text |= matches!(c.kind, NodeKind::Text(_));
|
||||
has_live |= matches!(c.kind, NodeKind::Path { live: Some(_), .. });
|
||||
has_fx |= !c.appearance.effects.is_empty();
|
||||
has_stroke |= matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && !c.appearance.stroke_paint().is_none();
|
||||
});
|
||||
}
|
||||
}
|
||||
if object && has_fx {
|
||||
let _ = run_raw(s, "effect.expandAppearance", &json!({}));
|
||||
}
|
||||
if object && has_live {
|
||||
let roots = selected_roots(s)?;
|
||||
s.edit("Expand", |d, _| {
|
||||
for r in &roots {
|
||||
if let Some(n) = d.node_mut(*r) {
|
||||
drop_live(n);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
if object && has_text {
|
||||
let _ = run_raw(s, "type.createOutlines", &json!({}));
|
||||
}
|
||||
if stroke && has_stroke {
|
||||
let _ = run_raw(s, "object.path.outlineStroke", &json!({}));
|
||||
}
|
||||
if !fill {
|
||||
// Fill unchecked: strokes only survive (Illustrator expands just the selected attributes).
|
||||
}
|
||||
if s.doc()?.revision == rev0 {
|
||||
return Err(EngineError::Other("Expand: nothing to expand".into()));
|
||||
}
|
||||
squash(s, from, "Expand");
|
||||
let ids = s.doc()?.selection.objects.clone();
|
||||
Ok(ids_json(&ids))
|
||||
}
|
||||
|
||||
fn drop_live(n: &mut Node) {
|
||||
if let NodeKind::Path { live, .. } = &mut n.kind {
|
||||
*live = None;
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
for c in ch.iter_mut() {
|
||||
drop_live(Arc::make_mut(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Rasterize / Crop ----------
|
||||
|
||||
pub(crate) const MAX_PIXELS: f64 = 64.0e6;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ mod docmenu;
|
|||
mod draw2;
|
||||
mod edit;
|
||||
mod effectcmd;
|
||||
pub mod expand;
|
||||
pub mod fileio;
|
||||
pub mod flatten;
|
||||
mod fonts;
|
||||
|
|
@ -202,6 +203,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(freeform::specs());
|
||||
v.extend(flatten::specs());
|
||||
v.extend(stylelib::specs());
|
||||
v.extend(expand::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -536,7 +536,11 @@ fn compound_release(s: &mut Session, _: &Value) -> Result<Value> {
|
|||
/// Make `top` (a path, compound path or text object) the clipping path: it loses its paint (it
|
||||
/// stays unpainted after Release).
|
||||
pub(super) fn make_clipping_path(d: &mut Document, top: NodeId) -> Result<()> {
|
||||
let c = d.node_mut(top).ok_or(EngineError::NoNode(top))?;
|
||||
as_clipping_path(d.node_mut(top).ok_or(EngineError::NoNode(top))?)
|
||||
}
|
||||
|
||||
/// Turn path, compound path or text `c` into a clipping path (its paint goes).
|
||||
pub(super) fn as_clipping_path(c: &mut Node) -> Result<()> {
|
||||
if !matches!(c.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } | NodeKind::Text(_)) {
|
||||
return Err(EngineError::Other("the top object must be a path, compound path or text object to use as a clipping mask".into()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -445,7 +445,7 @@ fn outlinable(st: &StrokeLayer) -> bool {
|
|||
|
||||
/// `n` with `opacity` and `blend` applied: set on it when it has none of its own, else on a
|
||||
/// group around it.
|
||||
fn with_transparency(d: &mut Document, mut n: Node, opacity: f32, blend: BlendMode) -> Node {
|
||||
pub(crate) fn with_transparency(d: &mut Document, mut n: Node, opacity: f32, blend: BlendMode) -> Node {
|
||||
if opacity >= 1.0 && blend == BlendMode::Normal {
|
||||
return n;
|
||||
}
|
||||
|
|
@ -503,24 +503,12 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br
|
|||
let (path, rule) = node_path(l)?;
|
||||
let mut parts: Vec<Node> = vec![];
|
||||
let mut fills: Vec<AppearanceItem> = vec![];
|
||||
let fill_node = |d: &mut Document, items: Vec<AppearanceItem>| {
|
||||
let mut f = d.reid(l);
|
||||
f.appearance = Appearance { items, ..Default::default() };
|
||||
f.opacity = 1.0;
|
||||
f.blend = BlendMode::Normal;
|
||||
f.name = None;
|
||||
f.mask = None;
|
||||
if let NodeKind::Path { live, .. } = &mut f.kind {
|
||||
*live = None;
|
||||
}
|
||||
f
|
||||
};
|
||||
for item in &l.appearance.items {
|
||||
match item {
|
||||
AppearanceItem::Fill(f) if f.visible && !f.paint.is_none() => fills.push(item.clone()),
|
||||
AppearanceItem::Stroke(st) if outlinable(st) => {
|
||||
if !fills.is_empty() {
|
||||
parts.push(fill_node(d, std::mem::take(&mut fills)));
|
||||
parts.push(styled_copy(d, l, std::mem::take(&mut fills)));
|
||||
}
|
||||
parts.extend(outlined_stroke(d, brushes, &path, rule, st));
|
||||
}
|
||||
|
|
@ -528,8 +516,28 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br
|
|||
}
|
||||
}
|
||||
if !fills.is_empty() {
|
||||
parts.push(fill_node(d, fills));
|
||||
parts.push(styled_copy(d, l, fills));
|
||||
}
|
||||
assemble(d, l, parts)
|
||||
}
|
||||
|
||||
/// A plain copy of path `l` (new ids, no transparency, name or mask) painted with `items`.
|
||||
pub(crate) fn styled_copy(d: &mut Document, l: &Node, items: Vec<AppearanceItem>) -> Node {
|
||||
let mut f = d.reid(l);
|
||||
f.appearance = Appearance { items, ..Default::default() };
|
||||
f.opacity = 1.0;
|
||||
f.blend = BlendMode::Normal;
|
||||
f.name = None;
|
||||
f.mask = None;
|
||||
if let NodeKind::Path { live, .. } = &mut f.kind {
|
||||
*live = None;
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// What stands for object `l` once its appearance became `parts` (paint order): the one part or a
|
||||
/// group of them, with `l`'s transparency, name, opacity mask and effects. `None` without parts.
|
||||
pub(crate) fn assemble(d: &mut Document, l: &Node, mut parts: Vec<Node>) -> Option<Node> {
|
||||
let root = match parts.len() {
|
||||
0 => return None,
|
||||
1 => parts.remove(0),
|
||||
|
|
@ -545,15 +553,18 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br
|
|||
/// Outline the strokes of every path under `root` in place: → (the id now standing for `root`,
|
||||
/// how many paths changed).
|
||||
pub(crate) fn outline_strokes_under(d: &mut Document, brushes: &[vectorcraft_brush::Brush], root: NodeId) -> Result<(NodeId, usize)> {
|
||||
replace_leaves(d, root, |d, l| if matches!(l.kind, NodeKind::Text(_)) { None } else { outline_strokes(d, brushes, l) })
|
||||
}
|
||||
|
||||
/// Replace each leaf shape under `root` ([`leaves`]) by what `f` makes of it (`None` keeps it):
|
||||
/// → (the id now standing for `root`, how many leaves changed).
|
||||
pub(crate) fn replace_leaves(d: &mut Document, root: NodeId, mut f: impl FnMut(&mut Document, &Node) -> Option<Node>) -> Result<(NodeId, usize)> {
|
||||
let Some(n) = d.node(root).cloned() else { return Ok((root, 0)) };
|
||||
let mut lv = vec![];
|
||||
leaves(&n, &mut lv);
|
||||
let (mut replacement, mut changed) = (root, 0);
|
||||
for l in lv {
|
||||
if matches!(l.kind, NodeKind::Text(_)) {
|
||||
continue;
|
||||
}
|
||||
let Some(new) = outline_strokes(d, brushes, &l) else { continue };
|
||||
let Some(new) = f(d, &l) else { continue };
|
||||
let (par, idx, _) = d.position(l.id).ok_or(EngineError::NoNode(l.id))?;
|
||||
let nid = d.insert(par, idx, new)?;
|
||||
d.remove(l.id)?;
|
||||
|
|
|
|||
|
|
@ -845,6 +845,8 @@ mod tests_maskview;
|
|||
#[cfg(test)]
|
||||
mod tests_menucmds;
|
||||
#[cfg(test)]
|
||||
mod tests_objexpand;
|
||||
#[cfg(test)]
|
||||
mod tests_opacitymask;
|
||||
#[cfg(test)]
|
||||
mod tests_outlinestroke;
|
||||
|
|
|
|||
|
|
@ -171,7 +171,8 @@ fn expand_live_shape_and_text_in_one_undo_step() {
|
|||
let t = text(&mut s, "Hi");
|
||||
sel(&mut s, &[a, t]);
|
||||
let n = undo_len(&s);
|
||||
s.execute("object.expand", &json!({})).unwrap();
|
||||
// Stroke off: the rectangle stays one path (its stroke isn't outlined).
|
||||
s.execute("object.expand", &json!({"stroke": false})).unwrap();
|
||||
assert!(matches!(node(&s, a).kind, NodeKind::Path { live: None, .. }));
|
||||
assert!(s.doc().unwrap().doc.node(t).is_none(), "text became outlines");
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,273 @@
|
|||
//! Object → Expand (M3.84): the Object, Fill and Stroke options, gradient fills into solid strips,
|
||||
//! concentric ellipses or a gradient mesh inside a clip group, freeform gradients into a mesh, and
|
||||
//! Create Gradient Mesh taking a gradient fill's colours.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> Node {
|
||||
s.doc().unwrap().doc.node(id).unwrap().clone()
|
||||
}
|
||||
|
||||
/// A selected rectangle filled with `gradient` (paint.setFill's), with no stroke unless `stroke`.
|
||||
fn gradient_rect(s: &mut Session, r: Rect, gradient: Value, stroke: bool) -> NodeId {
|
||||
let id = run(s, "shape.rectangle", json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height()}))["id"].as_u64().unwrap();
|
||||
run(s, "paint.setFill", json!({ "gradient": gradient }));
|
||||
if stroke {
|
||||
run(s, "paint.setStroke", json!({"color": "#0000ff"}));
|
||||
run(s, "stroke.set", json!({"weight": 4}));
|
||||
} else {
|
||||
run(s, "paint.setStroke", json!({"none": true}));
|
||||
}
|
||||
NodeId(id)
|
||||
}
|
||||
|
||||
/// The one selected object after a command.
|
||||
fn selected(s: &Session) -> Node {
|
||||
let sel = &s.doc().unwrap().selection.objects;
|
||||
assert_eq!(sel.len(), 1, "one object selected");
|
||||
node(s, sel[0])
|
||||
}
|
||||
|
||||
/// A clip group's clipping path and contents.
|
||||
fn clip_parts(n: &Node) -> (&Node, Vec<&Node>) {
|
||||
match &n.kind {
|
||||
NodeKind::Group { clip: true, children } => (&children[0], children[1..].iter().map(|c| &**c).collect()),
|
||||
k => panic!("expected a clip group, got {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn fill_color(n: &Node) -> Color {
|
||||
n.appearance.fill_paint().color().unwrap_or_else(|| panic!("solid fill expected on {:?}", n.kind))
|
||||
}
|
||||
|
||||
fn render(s: &Session) -> vectorcraft_render::Rendered {
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts)
|
||||
}
|
||||
|
||||
/// The mean difference of the colour channels of two renders.
|
||||
fn mean_diff(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered) -> f64 {
|
||||
let sum: u64 = a.pixels.iter().zip(b.pixels.iter()).map(|(x, y)| x.abs_diff(*y) as u64).sum();
|
||||
sum as f64 / a.pixels.len() as f64
|
||||
}
|
||||
|
||||
/// Pixels (of 200×200) whose colour differs by more than `tol` in any channel.
|
||||
fn differing(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered, tol: u8) -> usize {
|
||||
a.pixels.chunks_exact(4).zip(b.pixels.chunks_exact(4)).filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > tol)).count()
|
||||
}
|
||||
|
||||
fn white_black() -> Value {
|
||||
json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eight_strips_match_the_gradient_samples() {
|
||||
let mut s = session();
|
||||
gradient_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 120.0), white_black(), false);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
run(&mut s, "object.expand", json!({"steps": 8}));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
let g = selected(&s);
|
||||
let (clip, strips) = clip_parts(&g);
|
||||
assert!(matches!(clip.kind, NodeKind::Path { clipping: true, .. }));
|
||||
assert_eq!(clip.geometric_bounds().unwrap(), Rect::new(20.0, 20.0, 180.0, 120.0));
|
||||
assert_eq!(strips.len(), 8);
|
||||
for (k, strip) in strips.iter().enumerate() {
|
||||
// Band k starts at its eighth of the width (and runs on under the next bands, so no
|
||||
// seams show) and shows the gradient at its middle.
|
||||
let b = strip.geometric_bounds().unwrap();
|
||||
assert!((b.x0 - (20.0 + 20.0 * k as f64)).abs() < 1e-9, "strip {k} starts at {}", b.x0);
|
||||
assert!((b.x1 - 180.0).abs() < 1e-9 && (b.y0 - 20.0).abs() < 1e-9 && (b.y1 - 120.0).abs() < 1e-9);
|
||||
let gray = 1.0 - (k as f32 + 0.5) / 8.0;
|
||||
let [r, g, bl] = fill_color(strip).to_rgb();
|
||||
assert!((r - gray).abs() < 1e-4 && (g - gray).abs() < 1e-4 && (bl - gray).abs() < 1e-4, "strip {k}: {r} {g} {bl} vs {gray}");
|
||||
}
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(matches!(selected(&s).appearance.fill_paint(), Paint::Gradient(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_radial_gradient_becomes_concentric_ellipses() {
|
||||
let mut s = session();
|
||||
gradient_rect(&mut s, Rect::new(50.0, 50.0, 150.0, 150.0), json!({"kind": "radial"}), false);
|
||||
run(&mut s, "object.expand", json!({"steps": 4}));
|
||||
let g = selected(&s);
|
||||
let (_, rings) = clip_parts(&g);
|
||||
assert_eq!(rings.len(), 4);
|
||||
// Largest first, all centred on the gradient's centre; the largest reaches the box's corners.
|
||||
let reach = 50.0 * 2f64.sqrt();
|
||||
for (i, ring) in rings.iter().enumerate() {
|
||||
let b = ring.geometric_bounds().unwrap();
|
||||
assert!((b.center().x - 100.0).abs() < 1e-6 && (b.center().y - 100.0).abs() < 1e-6);
|
||||
let r = reach * (4 - i) as f64 / 4.0;
|
||||
assert!((b.width() / 2.0 - r).abs() < 1e-6 && (b.height() / 2.0 - r).abs() < 1e-6, "ring {i}: {b:?} vs radius {r}");
|
||||
}
|
||||
// The centre disc shows the gradient at the middle of the first band (white → black).
|
||||
let t = (reach / 8.0 / 50.0) as f32;
|
||||
let [r, ..] = fill_color(rings[3]).to_rgb();
|
||||
assert!((r - (1.0 - t)).abs() < 1e-4, "{r} vs {}", 1.0 - t);
|
||||
// Translucent stops: rings with holes instead of stacked discs.
|
||||
let mut s = session();
|
||||
let gr = json!({"kind": "radial", "stops": [{"offset": 0, "color": "#ff0000", "opacity": 0.5}, {"offset": 1, "color": "#0000ff"}]});
|
||||
gradient_rect(&mut s, Rect::new(50.0, 50.0, 150.0, 150.0), gr, false);
|
||||
run(&mut s, "object.expand", json!({"steps": 4}));
|
||||
let g = selected(&s);
|
||||
let (_, rings) = clip_parts(&g);
|
||||
let subpaths = |n: &Node| n.path_data().unwrap().subpaths.len();
|
||||
assert_eq!(rings.iter().map(|r| subpaths(r)).collect::<Vec<_>>(), [2, 2, 2, 1]);
|
||||
let Some(vectorcraft_doc::AppearanceItem::Fill(f)) = rings[3].appearance.items.first() else { panic!() };
|
||||
assert!(f.opacity < 1.0 && f.opacity > 0.5, "the centre band is translucent: {}", f.opacity);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mesh_option_makes_a_mesh_that_paints_the_gradient() {
|
||||
for gradient in [
|
||||
json!({"kind": "linear", "angle": 30, "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 0.4, "color": "#00ff00", "midpoint": 0.3}, {"offset": 1, "color": "#0000ff"}]}),
|
||||
json!({"kind": "radial", "stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 0.5, "color": "#ff00ff"}, {"offset": 1, "color": "#004080"}]}),
|
||||
] {
|
||||
let mut s = session();
|
||||
gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gradient.clone(), false);
|
||||
let before = render(&s);
|
||||
run(&mut s, "object.expand", json!({"gradient": "mesh"}));
|
||||
let g = selected(&s);
|
||||
let (_, content) = clip_parts(&g);
|
||||
assert_eq!(content.len(), 1);
|
||||
let NodeKind::Mesh(m) = &content[0].kind else { panic!("a mesh node: {:?}", content[0].kind) };
|
||||
assert!(m.is_valid());
|
||||
// The mesh is drawn as small flat pieces, so it is close but not identical.
|
||||
let after = render(&s);
|
||||
assert_eq!(differing(&before, &after, 48), 0, "{gradient}");
|
||||
assert!(mean_diff(&before, &after) < 2.5, "{gradient}: {}", mean_diff(&before, &after));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hard_stop_keeps_its_edge_in_the_mesh() {
|
||||
let mut s = session();
|
||||
let gr = json!({"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 0.5, "color": "#ff0000"}, {"offset": 0.5, "color": "#0000ff"}, {"offset": 1, "color": "#0000ff"}]});
|
||||
gradient_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), gr, false);
|
||||
run(&mut s, "object.expand", json!({"gradient": "mesh"}));
|
||||
let g = selected(&s);
|
||||
let (_, content) = clip_parts(&g);
|
||||
let NodeKind::Mesh(m) = &content[0].kind else { panic!() };
|
||||
// Columns at 0, 0.5 (red), 0.5 (blue), 1: the middle patch has no width.
|
||||
assert_eq!(m.cols, 3);
|
||||
let (a, b) = (&m.points[m.idx(0, 1)], &m.points[m.idx(0, 2)]);
|
||||
assert!(a.p.distance(b.p) < 1e-9);
|
||||
assert_eq!((a.color.to_hex(), b.color.to_hex()), ("#ff0000".into(), "#0000ff".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_freeform_gradient_expands_to_a_mesh_shaped_like_the_object() {
|
||||
let mut s = session();
|
||||
let gr = json!({"kind": "freeform", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 0.5, "color": "#00ff00"}, {"offset": 1, "color": "#0000ff"}]});
|
||||
gradient_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), gr, false);
|
||||
let before = render(&s);
|
||||
// Even with Specify … Objects.
|
||||
run(&mut s, "object.expand", json!({"gradient": "objects"}));
|
||||
let n = selected(&s);
|
||||
let NodeKind::Mesh(m) = &n.kind else { panic!("a mesh: {:?}", n.kind) };
|
||||
assert!(m.rows > 1 && m.cols > 1);
|
||||
let b = m.outline().bounds().unwrap();
|
||||
assert!((b.x0 - 20.0).abs() < 1e-6 && (b.y0 - 20.0).abs() < 1e-6 && (b.x1 - 180.0).abs() < 1e-6 && (b.y1 - 180.0).abs() < 1e-6, "{b:?}");
|
||||
let after = render(&s);
|
||||
assert!(mean_diff(&before, &after) < 2.5, "{}", mean_diff(&before, &after));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_gradient_mesh_takes_its_colours_from_the_gradient() {
|
||||
let mut s = session();
|
||||
let id = gradient_rect(&mut s, Rect::new(0.0, 0.0, 100.0, 50.0), white_black(), false);
|
||||
run(&mut s, "object.mesh.create", json!({"rows": 1, "cols": 2}));
|
||||
let NodeKind::Mesh(m) = &node(&s, id).kind else { panic!() };
|
||||
let hex = |r: usize, c: usize| m.points[m.idx(r, c)].color.to_hex();
|
||||
for r in 0..=1 {
|
||||
assert_eq!(hex(r, 0), "#ffffff", "left column white");
|
||||
assert_eq!(hex(r, 2), "#000000", "right column black");
|
||||
assert_eq!(hex(r, 1), "#808080", "middle grey");
|
||||
}
|
||||
// A solid fill still gives one colour, the highlight still lightens.
|
||||
let mut s = session();
|
||||
let id = NodeId(run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 50}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
|
||||
run(&mut s, "object.mesh.create", json!({"rows": 2, "cols": 2, "appearance": "center", "highlight": 100}));
|
||||
let NodeKind::Mesh(m) = &node(&s, id).kind else { panic!() };
|
||||
assert_eq!(m.points[m.idx(0, 0)].color.to_hex(), "#ff0000");
|
||||
assert_eq!(m.points[m.idx(1, 1)].color.to_hex(), "#ffffff");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanded_colours_keep_the_stops_colour_model() {
|
||||
let mut s = session();
|
||||
let gr = json!({"kind": "linear", "stops": [{"offset": 0, "color": {"c": 1, "m": 0, "y": 0, "k": 0}}, {"offset": 1, "color": {"c": 0, "m": 1, "y": 0, "k": 0}}]});
|
||||
gradient_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 120.0), gr, false);
|
||||
run(&mut s, "object.expand", json!({"steps": 4}));
|
||||
let g = selected(&s);
|
||||
let (_, strips) = clip_parts(&g);
|
||||
let Color::Cmyk { c, m, .. } = fill_color(strips[1]) else { panic!("CMYK strip expected: {:?}", fill_color(strips[1])) };
|
||||
assert!((c - 0.625).abs() < 1e-5 && (m - 0.375).abs() < 1e-5, "interpolated in CMYK: {c} {m}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_off_leaves_the_fill_live_and_stroke_off_keeps_the_stroke() {
|
||||
// Fill unchecked: the stroke is outlined, the gradient fill stays a gradient.
|
||||
let mut s = session();
|
||||
gradient_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 120.0), white_black(), true);
|
||||
run(&mut s, "object.expand", json!({"fill": false}));
|
||||
let g = selected(&s);
|
||||
let NodeKind::Group { children, clip: false } = &g.kind else { panic!("fill + outlined stroke group: {:?}", g.kind) };
|
||||
assert_eq!(children.len(), 2);
|
||||
assert!(matches!(children[0].appearance.fill_paint(), Paint::Gradient(_)), "the fill is still live");
|
||||
assert_eq!(fill_color(&children[1]).to_hex(), "#0000ff", "the stroke became a filled outline");
|
||||
// Stroke unchecked: the gradient is expanded and the stroke stays a stroke on a copy above.
|
||||
let mut s = session();
|
||||
gradient_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 120.0), white_black(), true);
|
||||
let before = render(&s);
|
||||
run(&mut s, "object.expand", json!({"stroke": false, "steps": 255}));
|
||||
let g = selected(&s);
|
||||
let NodeKind::Group { children, clip: false } = &g.kind else { panic!("{:?}", g.kind) };
|
||||
assert_eq!(clip_parts(&children[0]).1.len(), 255);
|
||||
assert_eq!(children[1].appearance.stroke_paint().color().unwrap().to_hex(), "#0000ff");
|
||||
assert!(children[1].appearance.fill_paint().is_none());
|
||||
assert_eq!(differing(&before, &render(&s), 2), 0, "looks the same");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_match_the_dialog_and_info_tells_what_applies() {
|
||||
let mut s = session();
|
||||
gradient_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 120.0), white_black(), true);
|
||||
assert_eq!(run(&mut s, "object.expand.info", json!({})), json!({"object": true, "fill": true, "stroke": true}));
|
||||
let r = run(&mut s, "object.expand", json!({}));
|
||||
assert_eq!(r["ids"].as_array().unwrap().len(), 1);
|
||||
// Object, Fill and Stroke on; 255 objects: the live rectangle, its stroke and its gradient.
|
||||
let g = selected(&s);
|
||||
let NodeKind::Group { children, .. } = &g.kind else { panic!("{:?}", g.kind) };
|
||||
let (clip, strips) = clip_parts(&children[0]);
|
||||
assert!(matches!(clip.kind, NodeKind::Path { live: None, .. }));
|
||||
assert_eq!(strips.len(), 255);
|
||||
assert!(children[1].appearance.stroke_paint().is_none(), "outlined stroke");
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Expand");
|
||||
assert_eq!(run(&mut s, "object.expand.info", json!({})), json!({"object": false, "fill": false, "stroke": false}));
|
||||
assert!(s.execute("object.expand", &json!({})).is_err(), "nothing left to expand");
|
||||
// Bad options are refused before anything changes.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.execute("object.expand", &json!({"gradient": "rings"})).is_err());
|
||||
assert!(s.execute("object.expand", &json!({"steps": 0})).is_err());
|
||||
assert!(matches!(selected(&s).kind, NodeKind::Path { live: Some(_), .. }));
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
//! Object → Expand: what to expand (Object: live shapes, type and effects; Fill: gradient fills;
|
||||
//! Stroke: strokes into filled outlines) and what gradient fills become (a gradient mesh, or a
|
||||
//! number of objects). Options the selection has nothing for are disabled. OK runs
|
||||
//! `object.expand` as one undo step.
|
||||
//!
|
||||
//! Fields: `object`, `fill`, `stroke`, `gradient` (`objects` | `mesh`), `steps` (1..1000), and
|
||||
//! `__object`, `__fill`, `__stroke` (that option has something to expand: `object.expand.info`).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cmd::expand::{DEFAULT_STEPS, MAX_STEPS};
|
||||
|
||||
use super::{DialogSpec, run_and_close};
|
||||
use crate::state::Dialog;
|
||||
use crate::{VectorcraftApp, widgets};
|
||||
|
||||
/// The dialog kind of Expand.
|
||||
pub const KIND: &str = "expand";
|
||||
|
||||
const CMD: &str = "object.expand";
|
||||
|
||||
/// The options and their checkbox labels.
|
||||
const OPTIONS: [(&str, &str); 3] = [("object", "Object"), ("fill", "Fill"), ("stroke", "Stroke")];
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Expand".into(), body, confirm, min_width: 260.0, ..DialogSpec::FORM };
|
||||
|
||||
/// Open Expand for the selection, every option on (as `object.expand {}` does).
|
||||
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
let info = app.session.execute("object.expand.info", &json!({})).map_err(|e| e.to_string())?;
|
||||
let mut fields = json!({"object": true, "fill": true, "stroke": true, "gradient": "objects", "steps": DEFAULT_STEPS});
|
||||
for (key, _) in OPTIONS {
|
||||
fields[format!("__{key}")] = info[key].clone();
|
||||
}
|
||||
app.ui.dialog = Some(Dialog::new(KIND, fields));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let set = |d: &mut Dialog, key: &str, v: Value| {
|
||||
d.fields.insert(key.into(), v);
|
||||
};
|
||||
widgets::subheader(ui, "Expand");
|
||||
for (key, label) in OPTIONS {
|
||||
let on = d.bool(key);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(12.0);
|
||||
if widgets::check(ui, label, on, d.bool(&format!("__{key}"))) {
|
||||
set(d, key, json!(!on));
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
widgets::subheader(ui, "Expand Gradient To");
|
||||
// Only gradient fills that Fill expands care.
|
||||
let enabled = d.bool("__fill") && d.bool("fill");
|
||||
let objects = d.str("gradient") != "mesh";
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(12.0);
|
||||
if widgets::radio(ui, "Gradient Mesh", !objects, enabled) {
|
||||
set(d, "gradient", json!("mesh"));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(12.0);
|
||||
if widgets::radio(ui, "Specify:", objects, enabled) {
|
||||
set(d, "gradient", json!("objects"));
|
||||
}
|
||||
ui.add_enabled_ui(enabled && objects, |ui| {
|
||||
if let Some(n) = widgets::plain_field(ui, "expand-steps", d.f64("steps", DEFAULT_STEPS as f64), "", 0, 52.0) {
|
||||
set(d, "steps", json!(n.round().clamp(1.0, MAX_STEPS as f64)));
|
||||
}
|
||||
widgets::dim_label(ui, "Objects");
|
||||
});
|
||||
});
|
||||
false
|
||||
}
|
||||
|
||||
/// `object.expand` parameters from the fields.
|
||||
fn params(d: &Dialog) -> Value {
|
||||
json!({
|
||||
"object": d.bool("object"),
|
||||
"fill": d.bool("fill"),
|
||||
"stroke": d.bool("stroke"),
|
||||
"gradient": d.str("gradient"),
|
||||
"steps": d.f64("steps", DEFAULT_STEPS as f64),
|
||||
})
|
||||
}
|
||||
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
run_and_close(app, CMD, params(d))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::doc::NodeKind;
|
||||
|
||||
fn frame(app: &mut VectorcraftApp) -> String {
|
||||
crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_with_what_applies_and_ok_expands_the_gradient_to_a_mesh() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 60})).unwrap();
|
||||
app.run("paint.setFill", json!({"gradient": {"kind": "linear"}})).unwrap();
|
||||
app.run("paint.setStroke", json!({"none": true})).unwrap();
|
||||
// The menu item (and `object.expand` without params) opens the dialog.
|
||||
crate::menus::invoke(&mut app, "object.expand", json!({}));
|
||||
let d = app.ui.dialog.as_ref().expect("the Expand dialog");
|
||||
assert_eq!(d.kind, KIND);
|
||||
assert_eq!((d.bool("__object"), d.bool("__fill"), d.bool("__stroke")), (true, true, false), "no stroke to expand");
|
||||
assert_eq!(params(d), json!({"object": true, "fill": true, "stroke": true, "gradient": "objects", "steps": 255.0}));
|
||||
let text = frame(&mut app);
|
||||
for label in ["Expand", "Object", "Fill", "Stroke", "Expand Gradient To", "Gradient Mesh", "Specify:", "Objects", "OK", "Cancel"] {
|
||||
assert!(text.contains(label), "{label} in {text}");
|
||||
}
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("gradient".into(), json!("mesh"));
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let st = app.session.doc().unwrap();
|
||||
assert_eq!(st.history.undo.len(), undo + 1);
|
||||
let g = st.doc.node(st.selection.objects[0]).unwrap();
|
||||
let NodeKind::Group { children, clip: true } = &g.kind else { panic!("a clip group: {:?}", g.kind) };
|
||||
assert!(matches!(children[1].kind, NodeKind::Mesh(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_command_palette_opens_it_and_ui_expand_dialog_too() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 20, "y": 20, "width": 100, "height": 60})).unwrap();
|
||||
app.run("ui.expandDialog", json!({})).unwrap();
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!((d.bool("__object"), d.bool("__fill"), d.bool("__stroke")), (true, false, true));
|
||||
frame(&mut app);
|
||||
// Explicit params still run the command directly.
|
||||
app.ui.dialog = None;
|
||||
crate::menus::invoke(&mut app, "object.expand", json!({"stroke": false}));
|
||||
assert!(app.ui.dialog.is_none());
|
||||
let st = app.session.doc().unwrap();
|
||||
assert!(matches!(st.doc.node(st.selection.objects[0]).unwrap().kind, NodeKind::Path { live: None, .. }));
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ mod command;
|
|||
pub mod confirm;
|
||||
mod document_setup;
|
||||
mod effect;
|
||||
pub mod expand;
|
||||
mod export_for_screens;
|
||||
pub mod eyedropper;
|
||||
pub mod flatten;
|
||||
|
|
@ -162,6 +163,7 @@ registry! {
|
|||
FlattenTransparency: [flatten::KIND] => flatten::SPEC,
|
||||
FlattenerPresets: [flattener_presets::KIND] => flattener_presets::SPEC,
|
||||
SaveStyleLibrary: [save_style_library::KIND] => save_style_library::SPEC,
|
||||
Expand: [expand::KIND] => expand::SPEC,
|
||||
}
|
||||
|
||||
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
|
||||
|
|
|
|||
|
|
@ -309,6 +309,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
|||
("window.userGraphicStyleLibrary8", "User Graphic Style Library 8", "", "{} open the 8. User Defined graphic style library"),
|
||||
("window.userGraphicStyleLibrary9", "User Graphic Style Library 9", "", "{} open the 9. User Defined graphic style library"),
|
||||
("window.userGraphicStyleLibrary10", "User Graphic Style Library 10", "", "{} open the 10. User Defined graphic style library"),
|
||||
(
|
||||
"ui.expandDialog",
|
||||
"Expand…",
|
||||
"",
|
||||
"{} open Expand for the selection (dialog `expand`, fields object, fill, stroke (all on; one the selection has nothing for is disabled, see object.expand.info), gradient: objects|mesh, steps 1..1000 (255)): OK runs object.expand with them as one undo step",
|
||||
),
|
||||
];
|
||||
|
||||
/// Handle a UI command. `None` = not a UI command (the engine handles it).
|
||||
|
|
@ -675,6 +681,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
Some(l) => crate::panels::graphic_styles::open_library(app, &json!({ "library": l.id })),
|
||||
None => Err("no such user library".into()),
|
||||
},
|
||||
"ui.expandDialog" => crate::dialogs::expand::open(app),
|
||||
_ => return None,
|
||||
};
|
||||
Some(r)
|
||||
|
|
@ -859,6 +866,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
|
|||
"ui.flattenTransparencyDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
|
||||
"ui.saveGraphicStyleLibrary" => app.session.active().is_some(),
|
||||
id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => crate::panels::graphic_styles::user_library(app, id).is_some(),
|
||||
"ui.expandDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
|
@ -1042,7 +1050,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
),
|
||||
c("Show All", "object.showAll"),
|
||||
Sep,
|
||||
cp("Expand…", "object.expand", json!({"object": true, "fill": true, "stroke": true})),
|
||||
c("Expand…", "ui.expandDialog"),
|
||||
c("Expand Appearance", "effect.expandAppearance"),
|
||||
c("Crop Image", "object.cropImage"),
|
||||
cp("Rasterize…", "object.rasterize", json!({"ppi": 72, "background": "transparent"})),
|
||||
|
|
@ -1656,6 +1664,13 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) {
|
|||
}
|
||||
return;
|
||||
}
|
||||
// Expand…: its dialog.
|
||||
if id == "object.expand" && p.as_object().is_none_or(|o| o.is_empty()) {
|
||||
if let Err(e) = crate::dialogs::expand::open(app) {
|
||||
app.status(e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Help links: the command returns the URL; a menu click opens it (agents just get the URL).
|
||||
if matches!(id, "help.discord" | "help.website" | "help.appPage" | "help.github") {
|
||||
app.open_link(id);
|
||||
|
|
|
|||
|
|
@ -138,3 +138,9 @@ overprints?, preset?, options?, showOptions?}` sets it (a preset loads its optio
|
|||
and refreshes its snapshot of the document, answering as `flattener.preview` does. `ui.inspect` reports the settings
|
||||
as `ui.flattener_preview`. The panel keeps its snapshot until Refresh (it notes when the document or the settings
|
||||
changed since); its preview greys the art and paints the highlighted objects or areas red.
|
||||
|
||||
Expand: Object → Expand… (`ui.expandDialog`, or `object.expand` without params from the menus, palette and shortcuts)
|
||||
opens the `expand` dialog for the selection. Fields: `object`, `fill`, `stroke` (all on; `__object`, `__fill`,
|
||||
`__stroke` say which the selection has something for, as `object.expand.info` does, and the others are disabled),
|
||||
`gradient` (`objects` or `mesh`) and `steps` (1–1000, default 255). `ui.dialog.confirm` runs `object.expand` with them as
|
||||
one undo step.
|
||||
|
|
|
|||
18
docs/mcp.md
18
docs/mcp.md
|
|
@ -549,3 +549,21 @@ Visual bounds (Fit to Selected Art, Rasterize, exporting all art) and clicks on
|
|||
in the whole stroke as drawn: arrowheads, inside/outside alignment (an outside stroke is hit outside the path, an
|
||||
inside one inside it), the width profile's width where you click, projecting caps and miter spikes up to the miter
|
||||
limit. A rectangle's right-angle miters stay within half the weight of its edges.
|
||||
|
||||
## Expand
|
||||
|
||||
`object.expand {object?, fill?, stroke?, gradient?, steps?}` (Object → Expand…) turns the selection into plain art in
|
||||
one undo step. `object` (on by default) outlines type, turns live shapes into paths and bakes effects; `stroke` (on)
|
||||
outlines strokes into filled paths; `fill` (on) expands gradient fills: with `gradient: "objects"` (the default) into
|
||||
`steps` (1–1000, default 255) solid objects (rectangles across a linear gradient, each from its band to the far end so
|
||||
no seams show; concentric ellipses for a radial one, largest first; just the bands when stops are translucent), with
|
||||
`gradient: "mesh"` into a gradient mesh that paints the gradient (columns or rings where its colour changes, sharp at
|
||||
coincident stops), each inside a clip group shaped like the object. A freeform gradient becomes a mesh shaped like the
|
||||
object either way. Expanded colours keep the colour model their stops share. With `fill: false` gradient fills stay
|
||||
live. `object.expand.info` answers which options have something to expand in the selection (`{object, fill,
|
||||
stroke}`), and `object.mesh.create` on a gradient-filled object colours the mesh points as the gradient paints them.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"object.expand","params":{"stroke":false,"gradient":"mesh"}}}
|
||||
{"name":"run_command","arguments":{"command":"object.expand","params":{"steps":16}}}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue