mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Appearance: the inline effect editor shows choice parameters as dropdowns with the dialog's labels
The Appearance panel's inline effect editor showed a parameter that
picks one of some values (Roughen's Points, Offset Path's Joins, the
menus of Smart Blur, Glass, Texturizer and De-Interlace) as a text
field holding the stored value, where the effect's dialog has a
dropdown. Its labels did not have the dialog's mappings ("Channel1"
and "Max Radius" for "Channel 1:" and "Max. Radius:"), and a plug-in
choice named mode whose value is a blend mode name showed the blend
mode list.
The editor now shows the other parameters that effect::choices lists
as dropdowns with the dialog's labels (Drop Shadow's Shade keeps its
radio buttons), translated in the "effect" context, and a pick sends
the dialog's value with effect.setParams. Glass's Texture menu ends
with Loaded Texture once a texture is loaded. A plug-in's choice lists
the plug-in's values, and the blend mode dropdown is only for our own
effects. The labels are form::humanize's without the colon. The dialog
and the panel both call form::choice_label and effect::glass_textures.
Docs: docs/target-app-parity.md.
This commit is contained in:
@@ -213,11 +213,11 @@ const GLASS: &str = "distort.glass";
|
||||
/// loaded, and Load Texture… with the loaded texture's size. Returns whether the values differ from
|
||||
/// those last previewed.
|
||||
fn glass_fields(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
use vectorcraft_effects::pixel::{GLASS_TEXTURES, GLASS_TEXTURES_LOADED, TEXTURE_IMAGE, texture};
|
||||
use vectorcraft_effects::pixel::{TEXTURE_IMAGE, texture};
|
||||
// The stored texture is shown as its size, not as a text field.
|
||||
let stored = d.fields.remove(TEXTURE_IMAGE);
|
||||
let size = stored.as_ref().and_then(Value::as_str).and_then(texture).map(|t| t.size());
|
||||
let menu: form::Choices = if size.is_some() { &GLASS_TEXTURES_LOADED } else { &GLASS_TEXTURES };
|
||||
let menu = glass_textures(size.is_some());
|
||||
let doc = vectorcraft_effects::effect_info(GLASS).map(|e| e.params).unwrap_or_default();
|
||||
let unit = app.session.general_unit();
|
||||
let pick = |k: &str| if k == "texture" { Some(menu) } else { choices(GLASS, k) };
|
||||
@@ -242,6 +242,12 @@ fn glass_fields(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) ->
|
||||
values_changed(d)
|
||||
}
|
||||
|
||||
/// Glass's Texture menu, ending with Loaded Texture once a texture is `loaded`.
|
||||
fn glass_textures(loaded: bool) -> form::Choices {
|
||||
use vectorcraft_effects::pixel::{GLASS_TEXTURES, GLASS_TEXTURES_LOADED};
|
||||
if loaded { &GLASS_TEXTURES_LOADED } else { &GLASS_TEXTURES }
|
||||
}
|
||||
|
||||
/// Glass's Load Texture…: an image file the user picks, read into `textureImage` as Glass stores
|
||||
/// it and picked in the Texture menu.
|
||||
fn load_texture(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> {
|
||||
@@ -280,6 +286,26 @@ fn choices(effect: &str, key: &str) -> Option<form::Choices> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The values that parameter `key` of applied effect `effect` picks from, as its dialog lists
|
||||
/// them, as (label, value): our own effects' labels in the UI language, a plug-in's choices as the
|
||||
/// plug-in names them. Glass's Texture menu ends with Loaded Texture when the effect's values
|
||||
/// `params` hold a loaded texture. `None` for a parameter that is not one of these lists.
|
||||
pub(crate) fn shown_choices(effect: &str, key: &str, params: &Map<String, Value>) -> Option<Vec<(String, String)>> {
|
||||
if let Some(plugin) = vectorcraft_plugins::effect::installed(effect) {
|
||||
return plugin.manifest().params.iter().find(|(k, _)| k == key).and_then(|(_, spec)| match spec {
|
||||
vectorcraft_plugins::ParamSpec::Choice { options, .. } => Some(options.iter().map(|o| (o.clone(), o.clone())).collect()),
|
||||
_ => None,
|
||||
});
|
||||
}
|
||||
let options = if effect == GLASS && key == "texture" {
|
||||
use vectorcraft_effects::pixel::{TEXTURE_IMAGE, texture};
|
||||
glass_textures(params.get(TEXTURE_IMAGE).and_then(Value::as_str).and_then(texture).is_some())
|
||||
} else {
|
||||
choices(effect, key)?
|
||||
};
|
||||
Some(options.iter().map(|(label, value)| (form::choice_label(label).to_string(), value.to_string())).collect())
|
||||
}
|
||||
|
||||
/// Where parameter `key` comes in the effect's parameter documentation `doc` (`{radius: …,
|
||||
/// threshold: …}`), which lists them in its dialog's order; the end when it isn't there.
|
||||
pub(super) fn doc_rank(doc: &str, key: &str) -> usize {
|
||||
|
||||
@@ -284,12 +284,9 @@ pub(super) fn param_fields(
|
||||
continue;
|
||||
}
|
||||
if let Some(options) = choices(&k) {
|
||||
// In the "effect" context: some languages read the plain Soft, Horizontal and
|
||||
// Vertical (Grain's types) as type settings.
|
||||
let shown = |label: &'static str| crate::i18n::tr_ctx(crate::i18n::current(), "effect", label);
|
||||
let cur = v.as_str().unwrap_or_default();
|
||||
let label = options.iter().find(|(_, value)| *value == cur).map_or(cur, |(l, _)| shown(l));
|
||||
let labels: Vec<&str> = options.iter().map(|(l, _)| shown(l)).collect();
|
||||
let label = options.iter().find(|(_, value)| *value == cur).map_or(cur, |(l, _)| choice_label(l));
|
||||
let labels: Vec<&str> = options.iter().map(|(l, _)| choice_label(l)).collect();
|
||||
if let Some((_, value)) = crate::widgets::dropdown_names(ui, ("fx-choice", &k), label, &labels, 140.0).and_then(|i| options.get(i)) {
|
||||
d.fields.insert(k, json!(value));
|
||||
changed = true;
|
||||
@@ -343,6 +340,12 @@ pub(super) fn param_fields(
|
||||
changed
|
||||
}
|
||||
|
||||
/// The label of one of [`param_fields`]' choices in the UI language, in the "effect" context: some
|
||||
/// languages read the plain Soft, Horizontal and Vertical (Grain's types) as type settings.
|
||||
pub(super) fn choice_label(label: &'static str) -> &'static str {
|
||||
crate::i18n::tr_ctx(crate::i18n::current(), "effect", label)
|
||||
}
|
||||
|
||||
/// A number typed in a command's dialog as JSON: a whole number as an integer, as counts are read
|
||||
/// (`as_u64`: Object Mosaic's columns and rows, #1000), which `12.0` isn't; a fraction stays one.
|
||||
/// Commands that take any number read either.
|
||||
@@ -404,7 +407,7 @@ pub(super) fn schema_fields(ui: &mut egui::Ui, d: &mut Dialog, specs: &[(String,
|
||||
}
|
||||
|
||||
/// A field label from its camelCase key (`miterLimit` → "Miter Limit:").
|
||||
pub(super) fn humanize(k: &str) -> String {
|
||||
pub(crate) fn humanize(k: &str) -> String {
|
||||
let mut s = String::new();
|
||||
for (i, c) in k.chars().enumerate() {
|
||||
if i == 0 {
|
||||
|
||||
@@ -110,13 +110,14 @@ use serde_json::{Value, json};
|
||||
pub use color_picker::open as open_color_picker;
|
||||
pub use document_setup::open as open_document_setup;
|
||||
pub use effect::open as open_effect_dialog;
|
||||
pub(crate) use effect::{FILM_GRAIN_FIELDS, SHADES};
|
||||
pub(crate) use effect::{FILM_GRAIN_FIELDS, SHADES, shown_choices};
|
||||
pub use export_as::open as open_export_as;
|
||||
pub use export_for_screens::open as open_export_for_screens;
|
||||
pub(crate) use export_for_screens::{
|
||||
KIND as EXPORT_FOR_SCREENS, formats as screen_formats, open_assets as open_export_for_screens_assets, saved_options as screen_saved_options,
|
||||
saved_rows as screen_saved_rows,
|
||||
};
|
||||
pub(crate) use form::humanize;
|
||||
pub(crate) use new_document::preset_name;
|
||||
pub use new_document::{open as open_new_document, preset_card};
|
||||
pub use png_options::open as open_raster_options;
|
||||
|
||||
@@ -724,8 +724,10 @@ fn has_options(id: &str) -> bool {
|
||||
vectorcraft_effects::default_params(id).is_some_and(|d| d.as_object().is_some_and(|o| !o.is_empty()))
|
||||
}
|
||||
|
||||
/// Inline editor for an applied effect's parameters (numbers, booleans, strings, colors, and
|
||||
/// Drop Shadow's Color or Darkness as radio buttons); edits go through `effect.setParams`.
|
||||
/// Inline editor for an applied effect's parameters, labeled as the effect dialogs' generic fields
|
||||
/// are: numbers, booleans, strings, colors, the values a parameter picks from as a dropdown
|
||||
/// ([`crate::dialogs::shown_choices`]), and Drop Shadow's Color or Darkness as radio buttons;
|
||||
/// edits go through `effect.setParams`.
|
||||
fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k: usize, e: &Effect) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let params = editor_params(e);
|
||||
@@ -740,16 +742,8 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k:
|
||||
}
|
||||
for (key, v) in ¶ms {
|
||||
ui.horizontal(|ui| {
|
||||
// A plug-in's parameter names are its own.
|
||||
let label = humanize(key);
|
||||
let label = super::label_or_name(&label, built_in);
|
||||
let label = param_label(key, built_in);
|
||||
ui.add_sized(vec2(84.0, 22.0), egui::Label::new(egui::RichText::new(label).size(11.5).color(t.text)).truncate());
|
||||
if let Some(cur) = widgets::blend_param(key, v) {
|
||||
if let Some(m) = widgets::blend_param_dropdown(ui, ("fx-blend", item, k, key.as_str()), cur) {
|
||||
change = Some((key.clone(), m));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if e.id == "stylize.dropShadow" && key == "shade" {
|
||||
let cur = v.as_str().unwrap_or_default();
|
||||
for (label, value) in crate::dialogs::SHADES {
|
||||
@@ -759,6 +753,25 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option<usize>, k:
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(options) = crate::dialogs::shown_choices(&e.id, key, ¶ms) {
|
||||
let cur = v.as_str().unwrap_or_default();
|
||||
let shown = options.iter().find(|(_, value)| value == cur).map_or(cur, |(label, _)| label.as_str());
|
||||
let labels: Vec<&str> = options.iter().map(|(label, _)| label.as_str()).collect();
|
||||
if let Some((_, value)) = widgets::dropdown_names(ui, ("fx-choice", item, k, key.as_str()), shown, &labels, 90.0)
|
||||
.and_then(|i| options.get(i))
|
||||
.filter(|(_, value)| value != cur)
|
||||
{
|
||||
change = Some((key.clone(), json!(value)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Only our own effects show a `mode` parameter as the blend mode dropdown; a plug-in's `mode` stays a plain field.
|
||||
if built_in && let Some(cur) = widgets::blend_param(key, v) {
|
||||
if let Some(m) = widgets::blend_param_dropdown(ui, ("fx-blend", item, k, key.as_str()), cur) {
|
||||
change = Some((key.clone(), m));
|
||||
}
|
||||
return;
|
||||
}
|
||||
match v {
|
||||
Value::Bool(b) => {
|
||||
if widgets::check_box(ui, label, *b, true) {
|
||||
@@ -814,20 +827,13 @@ fn editor_params(e: &Effect) -> serde_json::Map<String, Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// "offsetX" → "Offset X".
|
||||
pub fn humanize(key: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, ch) in key.chars().enumerate() {
|
||||
if i == 0 {
|
||||
out.extend(ch.to_uppercase());
|
||||
} else if ch.is_uppercase() {
|
||||
out.push(' ');
|
||||
out.push(ch);
|
||||
} else {
|
||||
out.push(ch);
|
||||
}
|
||||
}
|
||||
out
|
||||
/// The label of an effect's parameter `key` in [`effect_editor`]: the label the effect dialog's
|
||||
/// generic fields give it ([`crate::dialogs::humanize`]) without the ":" ("offsetX" → "Offset X",
|
||||
/// "maxRadius" → "Max. Radius"), in the UI language for one of our own effects (a plug-in's
|
||||
/// parameter names are shown as they are).
|
||||
fn param_label(key: &str, built_in: bool) -> String {
|
||||
let label = crate::dialogs::humanize(key);
|
||||
super::label_or_name(label.trim_end_matches(':'), built_in).to_string()
|
||||
}
|
||||
|
||||
/// Small swatch grid inside the appearance chip popup.
|
||||
@@ -994,8 +1000,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn labels() {
|
||||
assert_eq!(humanize("offsetX"), "Offset X");
|
||||
assert_eq!(humanize("blur"), "Blur");
|
||||
assert_eq!(param_label("offsetX", true), "Offset X");
|
||||
assert_eq!(param_label("blur", true), "Blur");
|
||||
assert!(!effect_label("stylize.dropShadow").contains('…'));
|
||||
assert_eq!(effect_label("path.outlineObject"), "Outline Object");
|
||||
assert_eq!(effect_label("x.unknownThing"), "unknownThing");
|
||||
@@ -1044,6 +1050,132 @@ mod tests {
|
||||
assert_eq!(n.appearance.effects[0].params["shade"], json!("darkness"));
|
||||
}
|
||||
|
||||
/// A rectangle with `effect` applied, its inline editor open, after a first frame in `ctx`.
|
||||
fn editing(effect: &str) -> (VectorcraftApp, egui::Context, NodeId) {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("effect.apply", json!({"effect": effect})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
set_pstate(&ctx, "ap-fx-open-None-0", true);
|
||||
panel_frame(&mut app, &ctx, vec![]);
|
||||
(app, ctx, NodeId(id))
|
||||
}
|
||||
|
||||
/// One frame of the panel in `ctx` with `events`: the texts drawn and where.
|
||||
fn panel_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Vec<(String, Rect)> {
|
||||
fn texts(s: &egui::Shape, out: &mut Vec<(String, Rect)>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let time = ctx.input(|i| i.time) + 0.1;
|
||||
let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
let mut v = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
|
||||
v
|
||||
}
|
||||
|
||||
/// Clicks the text `label` drawn in the panel (the last one drawn when there are several).
|
||||
fn click_text(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) {
|
||||
let shown = panel_frame(app, ctx, vec![]);
|
||||
let at = shown.iter().rev().find(|(s, _)| s == label).unwrap_or_else(|| panic!("no {label:?} in {shown:?}")).1.center();
|
||||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
for events in [vec![egui::Event::PointerMoved(at)], vec![button(true)], vec![button(false)]] {
|
||||
panel_frame(app, ctx, events);
|
||||
}
|
||||
}
|
||||
|
||||
fn shown_texts(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<String> {
|
||||
panel_frame(app, ctx, vec![]).into_iter().map(|(s, _)| s).collect()
|
||||
}
|
||||
|
||||
/// Roughen's Points and Offset Path's Joins are dropdowns with the effect dialog's labels, and
|
||||
/// a pick sends the dialog's value as one undo step.
|
||||
#[test]
|
||||
fn the_effect_editor_picks_choices_from_the_dialogs_dropdowns() {
|
||||
for (effect, key, shown, pick, value) in
|
||||
[("distort.roughen", "points", "Smooth", "Corner", "corner"), ("path.offsetPath", "joins", "Miter", "Round", "round")]
|
||||
{
|
||||
let (mut app, ctx, id) = editing(effect);
|
||||
let texts = shown_texts(&mut app, &ctx);
|
||||
assert!(texts.iter().any(|s| s == shown), "{effect}: {texts:?}");
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
click_text(&mut app, &ctx, shown);
|
||||
click_text(&mut app, &ctx, pick);
|
||||
let st = app.session.doc().unwrap();
|
||||
assert_eq!(st.doc.node(id).unwrap().appearance.effects[0].params[key], json!(value), "{effect}");
|
||||
assert_eq!(st.history.undo.len(), undo + 1, "{effect}");
|
||||
assert!(shown_texts(&mut app, &ctx).iter().any(|s| s == pick), "{effect}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The editor labels a parameter as the effect dialog does, without the ":".
|
||||
#[test]
|
||||
fn the_effect_editor_labels_parameters_as_the_dialog_does() {
|
||||
for (effect, labels) in [
|
||||
("pixelate.colorHalftone", &["Max. Radius", "Channel 1", "Channel 4"][..]),
|
||||
("video.deinterlace", &["Create New Fields by", "Duplication", "Eliminate", "Odd Fields"][..]),
|
||||
] {
|
||||
let (mut app, ctx, _) = editing(effect);
|
||||
let texts = shown_texts(&mut app, &ctx);
|
||||
for label in labels {
|
||||
assert!(texts.iter().any(|s| s == label), "{effect}: no {label:?} in {texts:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A plug-in's choice is a dropdown of the plug-in's own values, also when the parameter is
|
||||
/// called `mode` and its value names a blend mode.
|
||||
#[test]
|
||||
fn a_plug_in_choice_lists_the_plug_ins_values() {
|
||||
use vectorcraft_plugins::wat as tpl;
|
||||
let params = r#"{"mode":{"type":"choice","options":["normal","screen","dots"],"default":"screen"}}"#;
|
||||
let src = tpl::module(&tpl::manifest("test.ui.inlinechoice", "effect", params), tpl::ECHO, "", 1);
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap())})).unwrap();
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("effect.apply", json!({"effect": "plugin.test.ui.inlinechoice"})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
set_pstate(&ctx, "ap-fx-open-None-0", true);
|
||||
panel_frame(&mut app, &ctx, vec![]);
|
||||
click_text(&mut app, &ctx, "screen");
|
||||
click_text(&mut app, &ctx, "dots");
|
||||
let mode = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(NodeId(id)).unwrap().appearance.effects[0].params["mode"].clone();
|
||||
assert_eq!(mode(&app), json!("dots"));
|
||||
click_text(&mut app, &ctx, "dots");
|
||||
click_text(&mut app, &ctx, "screen");
|
||||
assert_eq!(mode(&app), json!("screen"));
|
||||
// Once the plug-in is removed, its value shows as it is, not as a blend mode.
|
||||
app.run("plugin.remove", json!({"id": "test.ui.inlinechoice"})).unwrap();
|
||||
let texts = shown_texts(&mut app, &ctx);
|
||||
assert!(texts.iter().any(|s| s == "screen") && !texts.iter().any(|s| s == "Screen"), "{texts:?}");
|
||||
}
|
||||
|
||||
/// Glass with a loaded texture lists Loaded Texture in its Texture menu, as its dialog does.
|
||||
#[test]
|
||||
fn the_effect_editor_lists_a_loaded_glass_texture() {
|
||||
let mut png = vec![];
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([90, 120, 150, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let stored = vectorcraft_engine::cmd::fileio::texture_image(&png).unwrap();
|
||||
let (mut app, ctx, id) = editing("distort.glass");
|
||||
app.run("effect.setParams", json!({"item": null, "index": 0, "params": {"texture": "loaded", "textureImage": stored}})).unwrap();
|
||||
let texts = shown_texts(&mut app, &ctx);
|
||||
assert!(texts.iter().any(|s| s == "Loaded Texture") && !texts.iter().any(|s| s == "loaded"), "{texts:?}");
|
||||
click_text(&mut app, &ctx, "Loaded Texture");
|
||||
click_text(&mut app, &ctx, "Frosted");
|
||||
let st = app.session.doc().unwrap();
|
||||
assert_eq!(st.doc.node(id).unwrap().appearance.effects[0].params["texture"], json!("frosted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_effect_editor_lists_earlier_parameters_in_their_current_form() {
|
||||
// A Scribble saved with one `variation` lists it as its Path Overlap and Spacing variations.
|
||||
|
||||
@@ -617,6 +617,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects.
|
||||
Fill and stroke paint (Fill, Stroke, None, Apply Last Color and Apply Last Gradient, also on characters selected with the Type tool), the Stroke panel's options, gradient and freeform point edits, Swap Fill and Stroke, Default Fill and Stroke, the Eyedropper, and the Appearance panel's item edits, Clear Appearance, Reduce to Basic Appearance, Remove Item, item reordering and Move Appearance record no undo step when they change nothing.
|
||||
A geometry effect on one of type's own fills or strokes reshapes the whole outline that the fill or stroke paints, on the canvas and in exports; PDF, SVG, EPS and the other vector exports write such type as paths. On type that holds inline graphics, those effects are drawn and exported only when the type has a geometry effect or perspective of its own.
|
||||
The Appearance panel's inline effect editor labels each parameter as the effect dialogs' generic fields do, without the colon ("Max. Radius", "Channel 1", "Create New Fields by"). Roughen's and Zig Zag's Points, Offset Path's Joins, Inner Glow's Source and the menus of Radial Blur, Smart Blur, Sprayed Strokes, Glass, Mezzotint, Halftone Pattern, Grain, Texturizer and De-Interlace are dropdowns with the dialog's labels that send the dialog's values, and a plug-in's choice is a dropdown of the plug-in's values.
|
||||
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document state; a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks.
|
||||
- **Masks and clipping:** opacity-mask controls work while the mask is being edited; transparency commands take ids and work without a selection; clipping sets clip by compound paths, even-odd paths, text and groups on screen and in raster export, matching SVG/PDF. Alt-click shows only the mask; SVG masks keep Clip and Invert; the transparency grid is per-document view state saved in native files (#1968); a clipping path's own fill and stroke paint behind and over the clipped art; layers can be clipping masks.
|
||||
- **Transparency:** three-state Knockout Group (on, neutral, off), Opacity & Mask Define Knockout Shape, Page Isolated Blending and Page Knockout Group, on screen and in SVG/PDF. SVG export writes a knockout group of opaque art plainly, without masks, so shapes meeting edge to edge leave no seam; translucent, blended, masked or brushed art knocks out through masks. Blend modes match the standard formulas on screen, in SVG and in PDF, and non-isolated groups blend with the art below. Flatten Transparency with presets, a dialog and a Flattener Preview panel.
|
||||
On the canvas, the renderer no longer draws the picture of the art below back as an image each time it copies the backdrop of a non-isolated group that blends: it keeps that picture and draws the art that follows over it. At 2880 × 1800, 50 translucent groups with Multiply members take 65 ms a frame instead of 299 ms on 8 threads (`vectorcraft-cli bench`), and the frames have the same pixels.
|
||||
|
||||
Reference in New Issue
Block a user