diff --git a/ROADMAP.md b/ROADMAP.md index 1de46b4b4d..ec3f6e9c76 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-14 · **Change:** minor (Tech Circuit brush library, G5; previously: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-15 · **Change:** minor (G5: built-in Neon & Glow graphic style library (Cyan Neon, Magenta Laser, Amber Tube, Electric Lime, Pulse Violet, Cobalt Phosphor); G5 estimate unchanged (3–13 h); previously: Tech Circuit brush library, G5; previously: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON @@ -225,6 +225,7 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | Date | Change | Summary | |---|---|---| +| 2026-11-15 | minor | Graphic Styles: built-in Neon & Glow graphic style library (Cyan Neon, Magenta Laser, Amber Tube, Electric Lime, Pulse Violet, Cobalt Phosphor); G5 estimate unchanged (3–13 h). | | 2026-11-14 | minor | Tech Circuit adds three original procedural brushes (Circuit Trace, Bus Lines, Terminal Nodes); ten built-in brush libraries. G5 remaining estimate 3–13 h (estimated); parity scores and headline totals unchanged. | | 2026-11-14 | minor | Gujarati (#6613): contributor proofreading completed for all 387 additions, with 11 wording corrections; 4,673 reviewed catalog rows. Status/error messages remain partial; web selection requires a bundled Gujarati face. Scores and estimates unchanged. | | 2026-11-10 | minor | Tidy-up, no content change: the unheaded change bullets at the top of the file and a revision row inside the headline-numbers table move to this table under their commit dates. Scores and estimates unchanged. | diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 20d0623d4d..6b16973cf1 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -358,6 +358,34 @@ mod tests { assert!((p.x - 25.0).abs() < 1e-6 && (p.y - 25.0).abs() < 1e-6, "{p:?}"); } + #[test] + fn coons_places_are_where_the_patch_bends() { + // A rectangle's sides have 65 evenly spaced points. + let [u, v] = Coons::rect(Rect::new(0.0, 0.0, 100.0, 50.0)).places(); + assert_eq!((u.len(), v.len()), (65, 65)); + assert!(u.iter().enumerate().all(|(i, x)| (x - i as f64 / 64.0).abs() < 1e-12), "{u:?}"); + // A top object with a curved top: between two places next to each other the patch is + // straight, in `u` and in `v`. + let mut path = kurbo::BezPath::new(); + path.move_to((0.0, 0.0)); + path.curve_to((30.0, -20.0), (70.0, 10.0), (100.0, 0.0)); + path.line_to((100.0, 50.0)); + path.line_to((40.0, 50.0)); + path.line_to((0.0, 50.0)); + path.close_path(); + let c = Coons::from_top_object(&PathData::from_bezpath(&path), Some([0, 1, 2, 4])).unwrap(); + let [u, v] = c.places(); + assert!(u.first() == Some(&0.0) && u.last() == Some(&1.0) && u.windows(2).all(|w| w[0] < w[1]), "{u:?}"); + assert!(v.first() == Some(&0.0) && v.last() == Some(&1.0) && v.windows(2).all(|w| w[0] < w[1]), "{v:?}"); + for w in u.windows(2) { + for y in [0.0, 0.3, 1.0] { + let mid = c.eval((w[0] + w[1]) / 2.0, y); + let half = c.eval(w[0], y).midpoint(c.eval(w[1], y)); + assert!(mid.distance(half) < 1e-9, "{w:?} {y}: {mid:?} {half:?}"); + } + } + } + #[test] fn maps_go_on_past_the_content_box() { // A stroke outline reaches past the box: a flat mesh and a rectangle stay the identity there. diff --git a/crates/doc/src/live/mesh.rs b/crates/doc/src/live/mesh.rs index b6d5bbc88d..ef0a9a908d 100644 --- a/crates/doc/src/live/mesh.rs +++ b/crates/doc/src/live/mesh.rs @@ -338,6 +338,31 @@ impl Coons { let end = |side: &[Point]| side.last().copied().unwrap_or_default(); [self.top[0], end(&self.top), end(&self.bottom), self.bottom[0]] } + + /// The places in `u` where the top or bottom side has a point, and the places in `v` where the + /// left or right side has one, rising from 0 to 1. Between two places next to each other, each + /// side runs straight, so [`Self::eval`] changes linearly in `u` and in `v` inside each cell of + /// the grid they make. + pub fn places(&self) -> [Vec; 2] { + let sides = [&self.top, &self.right, &self.bottom, &self.left]; + let at = |which: usize| -> Vec { + match self.knots.as_deref().and_then(|k| k.get(which)) { + Some(knots) => knots.clone(), + None => { + let last = sides[which].len().saturating_sub(1).max(1); + (0..=last).map(|i| i as f64 / last as f64).collect() + } + } + }; + let merge = |a: usize, b: usize| { + let mut v = at(a); + v.extend(at(b)); + v.sort_by(f64::total_cmp); + v.dedup(); + v + }; + [merge(0, 2), merge(1, 3)] + } } /// The most points one side of [`Coons::from_top_object`] holds while it adds points between its diff --git a/crates/doc/src/style_libs.rs b/crates/doc/src/style_libs.rs index 1952060663..34faf1f9c4 100644 --- a/crates/doc/src/style_libs.rs +++ b/crates/doc/src/style_libs.rs @@ -162,6 +162,7 @@ pub const STYLE_LIBRARIES: &[BuiltinStyleLibrary] = &[ BuiltinStyleLibrary { id: "blends-transparency", name: "Blends and Transparency", make: blends_transparency }, BuiltinStyleLibrary { id: "game-hud", name: "Game HUD", make: game_hud }, BuiltinStyleLibrary { id: "status-effects", name: "Status Effects", make: status_effects }, + BuiltinStyleLibrary { id: "neon-glow", name: "Neon & Glow", make: neon_glow }, ]; /// Built-in library `id`, made once. @@ -619,6 +620,44 @@ fn status_effects() -> Vec { ] } +/// Neon & Glow: original multi-layer procedural styles with neon tubes, laser beams, and glowing halos. +fn neon_glow() -> Vec { + vec![ + style( + "Cyan Neon", + vec![S(StrokeLayer { opacity: 0.7, ..round("#00e5ff", 6.0) }), S(round("#ffffff", 1.5))], + vec![fx("stylize.dropShadow", json!({"mode": "screen", "opacity": 85.0, "x": 0.0, "y": 0.0, "blur": 8.0, "color": "#00e5ff"}))], + ), + GraphicStyle { + blend: BlendMode::Screen, + ..style( + "Magenta Laser", + vec![S(StrokeLayer { blend: BlendMode::Screen, ..round("#ff007f", 5.0) }), S(round("#ffffff", 1.5))], + vec![fx("stylize.outerGlow", json!({"mode": "screen", "opacity": 90.0, "blur": 8.0, "color": "#ff007f"}))], + ) + }, + style( + "Amber Tube", + vec![S(StrokeLayer { opacity: 0.8, ..round("#ff8c00", 6.0) }), S(round("#ffea00", 1.5))], + vec![fx("stylize.outerGlow", json!({"mode": "screen", "opacity": 85.0, "blur": 8.0, "color": "#ff8c00"}))], + ), + style( + "Electric Lime", + vec![S(StrokeLayer { opacity: 0.75, ..round("#39ff14", 6.0) }), S(round("#ffffff", 1.5))], + vec![fx("stylize.outerGlow", json!({"mode": "screen", "opacity": 90.0, "blur": 8.0, "color": "#39ff14"}))], + ), + style( + "Pulse Violet", + vec![S(StrokeLayer { opacity: 0.75, ..round("#7b2cbf", 5.0) }), S(round("#e0aaff", 1.5))], + vec![ + fx("stylize.innerGlow", json!({"mode": "screen", "opacity": 75.0, "blur": 6.0, "color": "#c77dff", "source": "edge"})), + fx("stylize.outerGlow", json!({"mode": "screen", "opacity": 85.0, "blur": 8.0, "color": "#7b2cbf"})), + ], + ), + style("Cobalt Phosphor", vec![S(round("#0047ab", 6.0)), S(round("#ffffff", 1.5))], vec![fx("blur.gaussian", json!({"radius": 2.0}))]), + ] +} + #[cfg(test)] mod tests { use super::*; @@ -750,4 +789,36 @@ mod tests { let Paint::Gradient(gp) = g.appearance.fill_paint() else { panic!("a gradient") }; assert!(gp.swatch.is_none() && gp.gradient.stops.iter().all(|s| s.swatch.is_none() && s.tint == 1.0)); } + + #[test] + fn neon_and_glow_styles_are_defined_with_multi_strokes_and_effects() { + let lib = builtin_style_library("neon-glow").expect("neon-glow library exists"); + assert_eq!(lib.name, "Neon & Glow"); + assert_eq!(lib.len(), 6); + let names: Vec<&str> = lib.styles.iter().map(|g| g.name.as_str()).collect(); + assert_eq!(names, ["Cyan Neon", "Magenta Laser", "Amber Tube", "Electric Lime", "Pulse Violet", "Cobalt Phosphor"]); + for g in &lib.styles { + let strokes = g.appearance.items.iter().filter(|i| matches!(i, AppearanceItem::Stroke(_))).count(); + assert!(strokes >= 2, "{} has {} strokes, expected >= 2", g.name, strokes); + assert!(!g.appearance.effects.is_empty(), "{} has effects", g.name); + } + let cyan = lib.style("Cyan Neon").unwrap(); + assert_eq!(cyan.appearance.effects[0].id, "stylize.dropShadow"); + let magenta = lib.style("Magenta Laser").unwrap(); + assert_eq!(magenta.blend, BlendMode::Screen); + let pulse = lib.style("Pulse Violet").unwrap(); + let pulse_fx: Vec<&str> = pulse.appearance.effects.iter().map(|e| e.id.as_str()).collect(); + assert_eq!(pulse_fx, ["stylize.innerGlow", "stylize.outerGlow"]); + let cobalt = lib.style("Cobalt Phosphor").unwrap(); + assert_eq!(cobalt.appearance.effects[0].id, "blur.gaussian"); + } + + #[test] + fn neon_glow_round_trips_through_vcstyles() { + let lib = builtin_style_library("neon-glow").unwrap(); + let text = write(&lib); + assert!(sniff(&text)); + let read_back = read(&text, "Fallback").unwrap(); + assert_eq!(*lib, read_back); + } } diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 54d97a4527..d8f3ae0fe4 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -27,7 +27,9 @@ use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Vec2}; use crate::distort::{transform_move, transform_offsets}; use crate::group::{has_own_paint, paints}; -use crate::reshape::{as_art, clipping_text_outline, dressed, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; +use crate::reshape::{ + as_art, clipping_text_outline, dressed, geometry_effects, glyph_outlines, outline_nested_text, paint_type_clip, reshape_leaves, +}; use crate::{ GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry, has_instance_row_raster, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, reshape, @@ -689,6 +691,9 @@ fn moved_type_group(n: &Node) -> Option { /// them. `None` when no type stays type. fn container_keeping_type(d: &Document, n: &Node) -> Option { fn keep(d: &Document, n: &mut Node, moves: &[serde_json::Value], clipping: bool, kept: &mut bool) { + if !clipping { + paint_type_clip(Some(d), n); + } let clips = n.clips(); let Some(ch) = n.children_mut() else { return }; for (i, c) in ch.iter_mut().enumerate() { diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 1c1ca96d9f..62739d3688 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -182,29 +182,29 @@ pub fn effect_catalog() -> Vec { REVOLVE, "Revolve…", &["Effect", "3D and Materials"], - "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance; surfaces with graphics keep every face), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the swept side, see threeD.mapGraphic: symbol: name of a document symbol, face: front (the side), x: pt (0; the offset of the graphic's center from the middle of the side, around the axis: the angle times the profile's distance from the axis at that point), y: pt (0; along the profile, toward its lower end), scale: % (1..10000, 100), rotation: degrees (-360..360, 0; counterclockwise); wrapped around the side at its own size, so it reaches farther around where the profile comes nearer the axis, shaded with it and drawn only where the side's outside faces the viewer; a full turn cuts it at the back; nothing on the caps)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.", - json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"cap":true,"expandVisibleOnly":true,"graphics":[]}), + "{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance; surfaces with graphics keep every face), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the swept side, see threeD.mapGraphic: symbol: name of a document symbol, face: front (the side), x: pt (0; the offset of the graphic's center from the middle of the side, around the axis: the angle times the profile's distance from the axis at that point), y: pt (0; along the profile, toward its lower end), scale: % (1..10000, 100), rotation: degrees (-360..360, 0; counterclockwise); wrapped around the side at its own size, so it reaches farther around where the profile comes nearer the axis, shaded with it and drawn only where the side's outside faces the viewer; a full turn cuts it at the back; nothing on the caps), render: object ({mode: shaded}; mode: shaded|wireframe, wireframe draws the edges of the faces the effect draws, each once, stroked 0.5 pt black with round joins, with no fill, shadow or graphics, whatever the opacity of the fills, strokes and group members, while the effect stays live; the stroke is VectorCraft's own)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.", + json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"cap":true,"expandVisibleOnly":true,"graphics":[],"render":{"mode":"shaded"}}), ), g( EXTRUDE, "Extrude & Bevel…", &["Effect", "3D and Materials"], - "{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the caps, see threeD.mapGraphic: symbol: name of a document symbol, face: front|back (front; the back reads left to right as seen from behind), x: pt (0; the offset of the graphic's center from the center of the object's bounds, in the face's own plane), y: pt (0), scale: % (1..10000, 100; of the symbol's own size), rotation: degrees (-360..360, 0; counterclockwise); drawn turned, foreshortened and shaded with the cap and clipped to it, on a solid cap only)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).", - json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"graphics":[]}), + "{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the caps, see threeD.mapGraphic: symbol: name of a document symbol, face: front|back (front; the back reads left to right as seen from behind), x: pt (0; the offset of the graphic's center from the center of the object's bounds, in the face's own plane), y: pt (0), scale: % (1..10000, 100; of the symbol's own size), rotation: degrees (-360..360, 0; counterclockwise); drawn turned, foreshortened and shaded with the cap and clipped to it, on a solid cap only), render: object ({mode: shaded}; mode: shaded|wireframe, as Revolve's)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).", + json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"graphics":[],"render":{"mode":"shaded"}}), ), g( INFLATE, "Inflate…", &["Effect", "3D and Materials"], - "{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across)}. Painted with the top fill (else the top stroke).", - json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0}), + "{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), render: object ({mode: shaded}; mode: shaded|wireframe, as Revolve's)}. Painted with the top fill (else the top stroke).", + json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"render":{"mode":"shaded"}}), ), g( ROTATE, "Rotate…", &["Effect", "3D and Materials"], - "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the plane, as Extrude & Bevel's, with face front only)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.", - json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"graphics":[]}), + "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the plane, as Extrude & Bevel's, with face front only), render: object ({mode: shaded}; mode: shaded|wireframe, as Revolve's)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.", + json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"graphics":[],"render":{"mode":"shaded"}}), ), g( CLASSIC_EXTRUDE, diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index a326c85cf2..9c0c318725 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -91,12 +91,13 @@ pub use revolve::{ CLASSIC_REVOLVE, REVOLVE, has_revolve, is_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in, }; pub use solids::{ - CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, GRAPHICS, GraphicFrame, INFLATE, MAX_GRAPHICS, MappedGraphic, ROTATE, ROTATE_SURFACES, - ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, classic_shading, extrude_options, graphic_bounds, - graphic_frame, graphic_symbols, has_solid, inflate_options, is_classic_three_d, is_three_d, mapped_graphics, missing_symbol, - names_graphic_symbol, rename_graphic_symbol, rename_graphic_symbol_in, solid_art, solid_art_in, takes_graphics, three_d_meshes, - three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, three_d_wireframe_in, validate_solid, validate_solid_in, - validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, with_graphic, + CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, GRAPHICS, GraphicFrame, INFLATE, MAX_GRAPHICS, MappedGraphic, RENDER, RENDER_MODES, ROTATE, + ROTATE_SURFACES, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, classic_shading, extrude_options, + graphic_bounds, graphic_frame, graphic_symbols, has_solid, inflate_options, is_classic_three_d, is_three_d, mapped_graphics, missing_symbol, + names_graphic_symbol, rename_graphic_symbol, rename_graphic_symbol_in, renders_wireframe, solid_art, solid_art_in, takes_graphics, + three_d_meshes, three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, three_d_wireframe_in, validate_solid, + validate_solid_in, validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, + with_graphic, }; pub use solids::{ GRAPHIC_MAX_OFFSET, GRAPHIC_SCALE_RANGE, GRAPHICS_NOT_TAKEN, appearance_graphic_symbols, graphic_frame_at, graphics_reshaped_after, diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 70e5994cb5..4c8191fd2b 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -746,9 +746,13 @@ pub(crate) fn clipping_text_outline(t: &Node) -> Option { /// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look /// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes -/// its outlines with its visibility and effects ([`clipping_text_outline`]). The swatches and -/// patterns that live-effect plug-ins painting their pieces name are looked up in `doc`. +/// its outlines with its visibility and effects ([`clipping_text_outline`]), and its fills and +/// strokes become art of their own ([`paint_type_clip`]). The swatches and patterns that +/// live-effect plug-ins painting their pieces name are looked up in `doc`. pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping: bool) { + if !clipping { + paint_type_clip(doc, n); + } let clips = n.clips(); let Some(ch) = n.children_mut() else { return }; for (i, c) in ch.iter_mut().enumerate() { @@ -766,6 +770,36 @@ pub(crate) fn outline_nested_text(doc: Option<&Document>, n: &mut Node, clipping } } +/// When `n` is a clip group or clipping layer whose clipping path is type that paints +/// ([`Node::clip_paint`]), make it the art the canvas draws for it: the type's outlines clip +/// ([`clipping_text_outline`]), its fills as art ([`as_art`]) paint behind the clipped art inside +/// the clip, and its strokes as art paint over it, outside the clip, in a group that `n` becomes +/// (keeping its id, name and transparency) with the clip group inside. The other members stay as +/// they are. Hidden type paints nothing. +pub(crate) fn paint_type_clip(doc: Option<&Document>, n: &mut Node) { + let Some((first, rest)) = n.children().and_then(|ch| ch.split_first()) else { return }; + if !n.clips() || !matches!(first.kind, NodeKind::Text(_)) || !first.visible { + return; + } + let paint = first.clip_paint(); + if paint.fill.is_none() && paint.stroke.is_none() { + return; + } + let Some(clip) = clipping_text_outline(first) else { return }; + let art = |part: Option| part.and_then(|p| as_art(doc, &p, None)).map(Arc::new); + let members: Vec> = std::iter::once(Arc::new(clip)).chain(art(paint.fill)).chain(rest.iter().cloned()).collect(); + let stroke = art(paint.stroke); + let id = n.id; + match (&mut n.kind, stroke) { + (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }, None) => *children = members, + (NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. }, Some(stroke)) => { + *children = vec![Arc::new(Node::new(id, NodeKind::Group { children: members, clip: true })), stroke]; + *clip = false; + } + _ => {} + } +} + /// `n` (see [`needs_outline`]) with its object-level geometry effects and its perspective applied: /// its [`outline_art`] reshaped as one piece, or for an image the image clipped by its reshaped /// frame. Type's own fills and strokes keep their effects on its whole outline. An image's own diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index a9c9482d1d..47b1c83206 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -8,7 +8,7 @@ use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, N use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2}; use vectorcraft_three_d::{Edge, Revolve, SolidFaces}; -use crate::solids::wireframe::WireFace; +use crate::solids::wireframe::{WireFace, wireframe_params}; use crate::solids::{ Profile, Scope, is_container, members, object_profile, past_effect, profile, reshape_members, resolve_for, solid_paints, union_bounds, }; @@ -154,7 +154,12 @@ pub(crate) fn validate_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) let b = union_bounds(&shapes).ok_or(EMPTY)?; let options = shared_options(&shapes, options_of(id, p))?; let mut copies = 0; - shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items), &mut copies))?; + // In wireframe mode the edges are drawn with no paint copies, graphics or shadow. + let wire = wireframe_params(id, p); + shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items).filter(|_| !wire), &mut copies))?; + if wire { + return Ok(()); + } // Without graphics, or without the document they draw from, no member is measured for them. if let Some(doc) = doc.filter(|_| id == REVOLVE && !crate::solids::mapped_graphics(p).is_empty()) { match graphics_member(&shapes, b, options).and_then(|k| shapes.get(k)) { @@ -176,7 +181,11 @@ pub(crate) fn validate_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) let path = geometry(&path, before, &GeomContext::of(n)); let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; let paints = solid_paints(n); - check_surface(&path, b, options_of(id, p), surface_item(&paints), &mut 0)?; + let wire = wireframe_params(id, p); + check_surface(&path, b, options_of(id, p), surface_item(&paints).filter(|_| !wire), &mut 0)?; + if wire { + return Ok(()); + } check_graphics(doc, id, &path, b, options_of(id, p), p, &mut 0)?; check_shadow(p, [&path], b, options_of(id, p)) } @@ -261,7 +270,11 @@ pub(crate) fn validate_item_of(doc: Option<&Document>, n: &Node, id: &str, p: &V let options = options_of(kind, ¶ms); let path = geometry(&path, at.and_then(|at| effects.get(..at)).unwrap_or(effects), &ctx.item(item)); let b = path.bounds().ok_or(EMPTY)?; - check_surface(&path, b, options, crate::paints(item).then_some(item), &mut copies)?; + let wire = wireframe_params(kind, ¶ms); + check_surface(&path, b, options, (crate::paints(item) && !wire).then_some(item), &mut copies)?; + if wire { + continue; + } // The graphics of every item add their paints' copies to the graphics' budget, as they // are drawn; only the edited item's graphics are refused here. let graphics = check_graphics(doc, kind, &path, b, options, ¶ms, &mut graphic_copies); @@ -324,6 +337,7 @@ pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> { pub(crate) fn validate_params_of(id: &str, p: &Value) -> Result<(), String> { validate_revolve_params(p)?; crate::solids::graphics::validate(id, p)?; + crate::solids::wireframe::validate_render(id, p)?; if id == CLASSIC_REVOLVE { classic::validate(p)?; } @@ -728,14 +742,7 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul /// or stroke's Revolve are looked up in `doc`. Each face carries the width of the stroke the canvas /// draws around it ([`surface`]). pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result { - let faces = |path: &PathData, b: Rect, fx: &Effect, options: Revolve| -> Result, String> { - if fx.id == CLASSIC_REVOLVE { - let p = merged_params(&fx.id, &fx.params); - let facets = vectorcraft_three_d::revolve_facets(path, b, options)?; - return Ok(facets.iter().map(|f| WireFace { path: f.path(), stroke: crate::solids::classic::stroke_width(f, &p) }).collect()); - } - Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(|f| WireFace { path: f.path(), stroke: SEAM_WIDTH }).collect()) - }; + let faces = wire_faces; if is_container(n) { let index = n .appearance @@ -752,7 +759,9 @@ pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result, n: &Node) -> Result, n: &Node) -> Result Result, String> { + if fx.id == CLASSIC_REVOLVE { + let p = merged_params(&fx.id, &fx.params); + let facets = vectorcraft_three_d::revolve_facets(path, b, options)?; + return Ok(facets.iter().map(|f| WireFace { path: f.path(), stroke: crate::solids::classic::stroke_width(f, &p) }).collect()); + } + Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(|f| WireFace { path: f.path(), stroke: SEAM_WIDTH }).collect()) +} + +/// The art Revolve `fx` in wireframe mode draws for object `id` from the profiles `paths`, all swept +/// about the bounds `b` with `options`: the edges of their faces ([`crate::solids::wireframe::wire_art`]). +/// `None` when a profile can't be swept or past the edge limit, when the canvas draws the source art. +fn wire_art<'a>(id: NodeId, paths: impl IntoIterator, b: Rect, fx: &Effect, options: Revolve) -> Option> { + let mut faces = vec![]; + for path in paths { + faces.extend(wire_faces(path, b, fx, options).ok()?.into_iter().map(|f| f.path)); + } + crate::solids::wireframe::wire_art(id, &faces) +} + /// [`revolve_meshes`] of group or layer `n`: one mesh for each member, about one shared axis. fn group_meshes(n: &Node, total: &mut usize, limit: usize) -> Result, String> { let index = n @@ -824,13 +855,18 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { let paints = solid_paints(n); let item = surface_item(&paints)?; let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); - let (surface, kept) = object_surface(doc, n, &path, fx, item, expand, &mut copies)?; - if let Some(b) = path.bounds() - && let Some(shadow) = shadow_of(n.id, fx, &path, item, b, fx_options(fx), kept).ok()? - { - children.push(Arc::new(shadow)); + if crate::solids::renders_wireframe(fx) { + // In wireframe mode the edges of the faces, with no shadow. + children.push(wire_art(n.id, [&path], path.bounds()?, fx, fx_options(fx))?); + } else { + let (surface, kept) = object_surface(doc, n, &path, fx, item, expand, &mut copies)?; + if let Some(b) = path.bounds() + && let Some(shadow) = shadow_of(n.id, fx, &path, item, b, fx_options(fx), kept).ok()? + { + children.push(Arc::new(shadow)); + } + children.push(Arc::new(surface)); } - children.push(Arc::new(surface)); // Later geometry acts on the projected art as a whole through container evaluation. remaining = past_effect(&n.appearance.effects, index); remaining.retain(|e| !is_revolve(&e.id)); @@ -850,6 +886,13 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { let fx = item.effects().get(index)?; let p = geometry(&path, item.effects().get(..index)?, &ctx.item(original)); let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); + if crate::solids::renders_wireframe(fx) { + let mut art = wire_art(n.id, [&p], p.bounds()?, fx, fx_options(fx))?; + Arc::make_mut(&mut art).appearance.effects = + item.effects().iter().skip(index + 1).filter(|e| !is_revolve(&e.id)).cloned().collect(); + children.push(art); + continue; + } let (mut art, kept) = object_surface(doc, n, &p, fx, item, expand, &mut copies)?; art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| !is_revolve(&e.id)).cloned().collect(); if let Some(b) = p.bounds() @@ -999,6 +1042,14 @@ fn group_art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes); let b = union_bounds(&shapes)?; let options = shared_options(&shapes, fx_options(fx)).ok()?; + if crate::solids::renders_wireframe(fx) { + // In wireframe mode the edges of every member's faces, with no shadow. + let paths = shapes.iter().filter(|s| surface_item(&s.items).is_some()).map(|s| &s.path); + let mut art = n.clone(); + art.kind = NodeKind::Group { children: vec![wire_art(n.id, paths, b, fx, options)?], clip: false }; + art.appearance = Appearance { effects: past_effect(&n.appearance.effects, index), ..Default::default() }.into(); + return Some(art); + } let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id)); let mut copies = Copies::default(); let mut surfaces = vec![]; diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index c0d44947d7..953768d298 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -36,7 +36,7 @@ pub use graphics::{ pub(crate) mod shadow; pub(crate) mod wireframe; -pub use wireframe::three_d_wireframe_in; +pub use wireframe::{RENDER, RENDER_MODES, renders_wireframe, three_d_wireframe_in}; pub const EXTRUDE: &str = "threeD.extrude"; pub const INFLATE: &str = "threeD.inflate"; @@ -274,6 +274,7 @@ fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> { crate::lighting::validate(p)?; shadow::validate(p)?; graphics::validate(id, p)?; + wireframe::validate_render(id, p)?; if let Some(v) = p.get("bevel") && !v.as_str().is_some_and(|s| s == "none" || BevelShape::parse(s).is_some()) { @@ -324,7 +325,7 @@ fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?; check_profiles(id, p, &shapes, b)?; check_solids(id, p, &shapes)?; - return cast_shadow(id, p, &shapes).map(|_| ()); + return check_cast(id, p, &shapes); } let (path, rule) = object_profile(n, label)?; let anchors = || format!("{label} requires a shape with at least two anchors"); @@ -342,7 +343,16 @@ fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: let shapes = [Profile { path, rule, items, opacity: 1.0, scopes: vec![] }]; check_profiles(id, p, &shapes, b)?; check_solids(id, p, &shapes)?; - cast_shadow(id, p, &shapes).map(|_| ()) + check_cast(id, p, &shapes) +} + +/// With the shadow of 3D effect `id` with parameters `p` on, an error when the shadow of `shapes` +/// is past the limits ([`cast_shadow`]). In wireframe mode no shadow is drawn, and none is checked. +fn check_cast(id: &str, p: &Value, shapes: &[Profile]) -> Result<(), String> { + if wireframe::wireframe_params(id, p) { + return Ok(()); + } + cast_shadow(id, p, shapes).map(|_| ()) } /// Do the solids 3D effect `id` with parameters `p` builds from the profiles of `shapes` about `b` @@ -1420,6 +1430,10 @@ type ShadowAndSurfaces = (Vec>, Vec>); /// off), cast from the faces the solids drawn kept ([`cast_built`]), to be drawn behind every /// surface of the object, and the surfaces. fn art_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile]) -> Option { + // In wireframe mode the effect draws the edges of the faces it builds, with no shadow. + if wireframe::renders_wireframe(fx) { + return Some((vec![], wireframe::wire_surfaces(n.id, fx, shapes)?)); + } let p = crate::merged_params(&fx.id, &fx.params); let cast = !is_classic_three_d(&fx.id) && shadow::options(&p).is_some(); let (art, built) = surfaces_for(doc, n, fx, shapes, cast)?; @@ -1434,8 +1448,11 @@ fn art_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile]) -> } /// The shadow [`art_for`] draws for effect `fx` on `shapes` behind the surfaces of object `id`: -/// none with the shadow off, and an error when it is past the limits. +/// none with the shadow off or in wireframe mode, and an error when it is past the limits. pub(crate) fn shadow_for(id: NodeId, fx: &Effect, shapes: &[Profile]) -> Result>, String> { + if wireframe::renders_wireframe(fx) { + return Ok(vec![]); + } Ok(match cast_shadow(&fx.id, &crate::merged_params(&fx.id, &fx.params), shapes)? { Some((caster, settings, cast)) => shadow::scoped_art(id, &caster, settings, cast, has_masks(shapes)), None => vec![], diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs index 410d51443f..65ac8b0f7e 100644 --- a/crates/effects/src/solids/wireframe.rs +++ b/crates/effects/src/solids/wireframe.rs @@ -1,22 +1,91 @@ //! Expand as Wireframes: the edges of the faces a 3D effect draws, as open and closed paths that -//! can be edited on their own. +//! can be edited on their own. Also the 3D and Materials panel's Render Settings › Wireframe, which +//! draws those edges in place of the shaded faces while the effect stays live. use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::Arc; +use serde_json::Value; use vectorcraft_doc::color::{Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Node, NodeId}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, LineJoin, Node, NodeId, StrokeLayer}; use vectorcraft_geom::{Anchor, CubicBez, PathData, Point, SubPath}; use vectorcraft_three_d::Facet; use super::{ - CLASSIC_EXTRUDE, CLASSIC_ROTATE, Profile, ROTATE, built_solids, facets, geometry, has_solid, is_container, is_plane, is_three_d, label_of, - members, object_profile, region, reshape_members, resolve, resolve_for, solid, solid_paint_sources, solid_parts, union_bounds, + CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, INFLATE, Profile, ROTATE, built_solids, facets, geometry, has_solid, is_container, is_plane, + is_three_d, label_of, members, object_profile, region, reshape_members, resolve, resolve_for, solid, solid_paint_sources, solid_parts, + union_bounds, }; use crate::{GeomContext, is_geometry}; /// The most distinct edges one object's wireframe may have. pub(crate) const MAX_WIRE_EDGES: usize = 1_000_000; +/// The parameter that holds a 3D effect's render settings: an object whose `mode` is one of +/// [`RENDER_MODES`]. +pub const RENDER: &str = "render"; + +/// The render modes of a 3D effect: `shaded` draws the lit faces, `wireframe` the edges of every +/// face ([`renders_wireframe`]). +pub const RENDER_MODES: [&str; 2] = ["shaded", "wireframe"]; + +/// The 3D effects that take [`RENDER`]: the 3D and Materials panel's Extrude & Bevel, Revolve, +/// Inflate and Rotate. The 3D (Classic) effects have their own Wireframe surface. +const TAKES_RENDER: [&str; 4] = [EXTRUDE, crate::REVOLVE, INFLATE, ROTATE]; + +/// The weight of a rendered wireframe edge, in points: the weight Expand as Wireframes strokes its +/// edges with (VectorCraft's own). +pub(crate) const WIRE_WEIGHT: f64 = 0.5; + +/// Does effect `fx` draw as a wireframe: is it one of the effects that take [`RENDER`], with its +/// `render.mode` set to `wireframe`? +pub fn renders_wireframe(fx: &Effect) -> bool { + wireframe_params(&fx.id, &fx.params) +} + +/// Do 3D effect `id`'s parameters `p` set it to draw as a wireframe ([`renders_wireframe`])? A +/// command that sets them is checked against the art that mode draws: the faces' edges, with no +/// shadow, graphics or paint copies. +pub(crate) fn wireframe_params(id: &str, p: &Value) -> bool { + TAKES_RENDER.contains(&id) && p.get(RENDER).and_then(|r| r.get("mode")).and_then(Value::as_str) == Some("wireframe") +} + +/// Check the render settings in 3D effect `id`'s parameters `p`: `render`, when given to one of the +/// effects in [`TAKES_RENDER`], is an object that holds only `mode`, one of [`RENDER_MODES`] (a +/// missing `mode` is `shaded`). The 3D (Classic) effects refuse it as a parameter they do not take. +pub(crate) fn validate_render(id: &str, p: &Value) -> Result<(), String> { + let Some(render) = p.get(RENDER).filter(|_| TAKES_RENDER.contains(&id)) else { return Ok(()) }; + let valid = render + .as_object() + .is_some_and(|fields| fields.iter().all(|(key, value)| key == "mode" && value.as_str().is_some_and(|m| RENDER_MODES.contains(&m)))); + if !valid { + return Err("`render` must be an object holding only `mode`, set to shaded or wireframe".into()); + } + Ok(()) +} + +/// The art a 3D effect in wireframe mode draws for object `id` in place of its shaded faces: a group +/// of the edges of `faces`, each edge once ([`edges`]), stroked [`WIRE_WEIGHT`] black with round +/// joins and no fill, as Expand as Wireframes strokes them. Like Expand as Wireframes and the +/// Outline view, the mesh shows every edge whatever the opacity, blending mode or opacity mask of +/// the fills, strokes and group members it is built from; the object's own transparency applies to +/// the art as a whole. `None` past [`MAX_WIRE_EDGES`] edges, when the canvas draws the source art, +/// as it does for a solid past its limits. +pub(crate) fn wire_art(id: NodeId, faces: &[PathData]) -> Option> { + let mut stroke = StrokeLayer::new(Paint::solid(Color::BLACK), WIRE_WEIGHT); + stroke.join = LineJoin::Round; + let appearance = Appearance { items: vec![AppearanceItem::Stroke(stroke)], ..Default::default() }; + let children = edges(faces).ok()?.into_iter().map(|path| Arc::new(Node::path(id, path, appearance.clone()))).collect(); + Some(Arc::new(Node::group(id, children))) +} + +/// The art 3D effect `fx` in wireframe mode draws for object `id` from `shapes`: the edges of the +/// faces it builds ([`wire_art`]). `None` when a solid can't be built or past the edge limit, when +/// the canvas draws the source art. +pub(crate) fn wire_surfaces(id: NodeId, fx: &Effect, shapes: &[Profile]) -> Option>> { + let faces: Vec = facet_paths(fx, shapes).ok()?.into_iter().map(|f| f.path).collect(); + Some(vec![wire_art(id, &faces)?]) +} + /// The grid points are rounded to before edges are compared, in points: two faces that share an /// edge give it once. const GRID: f64 = 1.0 / 1024.0; @@ -171,7 +240,7 @@ fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result Result, String> { +pub(crate) fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result, String> { let label = label_of(&fx.id); let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; let p = crate::merged_params(&fx.id, &fx.params); diff --git a/crates/effects/src/solids/wireframe/tests.rs b/crates/effects/src/solids/wireframe/tests.rs index 735be60d6b..cb92d59e72 100644 --- a/crates/effects/src/solids/wireframe/tests.rs +++ b/crates/effects/src/solids/wireframe/tests.rs @@ -281,6 +281,210 @@ fn a_clipping_group_with_revolve_and_rotate_expands_the_art_revolve_cuts() { assert_eq!(both, alone); } +/// The leaves `art` draws: each path with its fill and stroke. +fn leaves(art: &Node) -> Vec<(PathData, bool, Option)> { + let mut out = vec![]; + art.walk(&mut |c| { + if let Some(p) = c.path_data() { + out.push((p.clone(), c.appearance.fill().is_some(), c.appearance.stroke().cloned())); + } + }); + out +} + +/// The art the canvas draws for `n`: Revolve's, else the other 3D effects'. +fn drawn(n: &Node) -> Node { + crate::revolve_art(n).or_else(|| super::super::solid_art(n)).unwrap_or_else(|| panic!("no art for {n:?}")) +} + +const WIRE: [&str; 4] = [super::super::EXTRUDE, crate::REVOLVE, super::super::INFLATE, ROTATE]; + +/// Render Settings › Wireframe: each of the panel's four effects draws the edges Expand as +/// Wireframes gives, each a 0.5 pt black stroke with round joins and no fill, in place of its +/// shaded faces; shaded again, it draws filled faces. +#[test] +fn wireframe_mode_draws_the_expanded_edges_as_strokes() { + for id in WIRE { + let n = solid_square(id, json!({"render": {"mode": "wireframe"}})); + let art = leaves(&drawn(&n)); + let wire = three_d_wireframe_in(None, &n).unwrap_or_else(|e| panic!("{id}: {e}")); + assert_eq!(art.len(), wire.len(), "{id}"); + for ((path, filled, stroke), edge) in art.iter().zip(&wire) { + assert!(!filled, "{id}: an edge has no fill"); + let stroke = stroke.as_ref().unwrap_or_else(|| panic!("{id}: an edge is stroked")); + assert_eq!((stroke.width, stroke.join, &stroke.paint), (WIRE_WEIGHT, LineJoin::Round, &Paint::solid(Color::BLACK)), "{id}"); + assert_eq!(segments(std::slice::from_ref(path)), segments(std::slice::from_ref(edge)), "{id}"); + } + let shaded = leaves(&drawn(&solid_square(id, json!({"render": {"mode": "shaded"}})))); + assert!(shaded.iter().any(|(_, filled, _)| *filled), "{id}: shaded faces are filled"); + } + // The box turned off axis shows the nine edges of its three faces. + let n = solid_square(super::super::EXTRUDE, json!({"render": {"mode": "wireframe"}})); + let paths: Vec = leaves(&drawn(&n)).into_iter().map(|(p, ..)| p).collect(); + assert_eq!(segments(&paths).len(), 9); +} + +/// The wireframe stays live: a deeper extrusion and a turned view redraw the edges. +#[test] +fn wireframe_mode_follows_the_effects_settings() { + let paths = |p: Value| -> Vec { leaves(&drawn(&solid_square(super::super::EXTRUDE, p))).into_iter().map(|(p, ..)| p).collect() }; + let flat = bounds(&paths(json!({"render": {"mode": "wireframe"}, "depth": 10.0}))); + let deep = bounds(&paths(json!({"render": {"mode": "wireframe"}, "depth": 200.0}))); + assert!(deep.width() > flat.width() + 1.0 || deep.height() > flat.height() + 1.0, "{flat:?} {deep:?}"); + let turned = paths(json!({"render": {"mode": "wireframe"}, "rotationY": 60.0})); + assert_ne!(segments(&turned), segments(&paths(json!({"render": {"mode": "wireframe"}})))); +} + +/// In wireframe mode no shadow and no graphic is drawn: only the edges. +#[test] +fn wireframe_mode_draws_no_shadow() { + for id in WIRE { + let n = solid_square(id, json!({"render": {"mode": "wireframe"}, "shadow": true})); + let art = leaves(&drawn(&n)); + assert!(!art.is_empty() && art.iter().all(|(_, filled, stroke)| !filled && stroke.is_some()), "{id}"); + } +} + +/// The mode works where the effect is on a fill and on a group: the fill's solid and every member's +/// solid draw as edges, and the geometry effects after a fill's effect still apply to them. +#[test] +fn wireframe_mode_on_a_fill_and_on_a_group() { + for id in WIRE { + let mut n = solid_square(id, json!({})); + let mut fx = n.appearance.effects.remove(0); + fx.params["render"] = json!({"mode": "wireframe"}); + let fill = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))).unwrap(); + fill.effects_mut().push(fx.clone()); + fill.effects_mut().push(crate::new_effect("distort.transform", &json!({"moveH": 500.0})).unwrap()); + let art = leaves(&drawn(&n)); + assert!(!art.is_empty() && art.iter().all(|(_, filled, stroke)| !filled && stroke.is_some()), "{id} on a fill"); + + let member = |x: f64, id: u64| { + Arc::new(Node::path(NodeId(id), square(x, 100.0, 50.0), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0))) + }; + let mut g = Node::group(NodeId(1), vec![member(100.0, 2), member(200.0, 3)]); + g.appearance.effects.push(fx); + let art = leaves(&drawn(&g)); + assert!(!art.is_empty() && art.iter().all(|(_, filled, stroke)| !filled && stroke.is_some()), "{id} on a group"); + // Both members' edges are drawn. + let paths: Vec = art.into_iter().map(|(p, ..)| p).collect(); + assert!(bounds(&paths).width() > 100.0, "{id}"); + } +} + +/// The mesh shows every edge, as Expand as Wireframes gives them, whatever the opacity of the fill +/// and the opacity masks of the group's members; the object's own opacity stays on the art. Two +/// members that touch, one at 50% and one masked, draw their shared edge once: seven edges. +#[test] +fn the_mesh_shows_every_edge_whatever_the_members_transparency() { + let member = |x: f64, id: u64, opacity: f32| { + let mut n = Node::path(NodeId(id), square(x, 100.0, 50.0), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) { + f.opacity = opacity; + } + n + }; + let mut masked = member(150.0, 3, 1.0); + masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(member(0.0, 900, 1.0), true))); + let mut g = Node::group(NodeId(1), vec![Arc::new(member(100.0, 2, 0.5)), Arc::new(masked)]); + g.opacity = 0.75; + g.appearance.effects.push(crate::new_effect(ROTATE, &json!({"render": {"mode": "wireframe"}})).unwrap()); + let art = drawn(&g); + assert_eq!(art.opacity, 0.75); + let paths: Vec = leaves(&art).into_iter().map(|(p, ..)| p).collect(); + assert_eq!(segments(&paths).len(), 7, "{paths:?}"); + assert_eq!(segments(&paths).len(), segments(&three_d_wireframe_in(None, &g).unwrap()).len()); + let mut scoped = 0; + for c in art.children().into_iter().flatten() { + c.walk(&mut |c| scoped += usize::from(c.extras().mask.is_some() || c.opacity < 1.0)); + } + assert_eq!(scoped, 0, "no part of the mesh is masked or faded"); + // A fill at 0% still shows its mesh. + for id in WIRE { + let mut n = solid_square(id, json!({"render": {"mode": "wireframe"}})); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) { + f.opacity = 0.0; + } + assert!(!leaves(&drawn(&n)).is_empty(), "{id}"); + } +} + +/// Expand Appearance of a Revolve in wireframe mode gives the stroked edges, not the shaded faces. +#[test] +fn expanding_a_revolve_in_wireframe_mode_gives_the_edges() { + let n = solid_square(crate::REVOLVE, json!({"render": {"mode": "wireframe"}})); + let art = leaves(&crate::revolve::expand_revolve_art(&n).unwrap()); + assert_eq!(art.len(), three_d_wireframe_in(None, &n).unwrap().len()); + assert!(art.iter().all(|(_, filled, stroke)| !filled && stroke.as_ref().is_some_and(|s| s.width == WIRE_WEIGHT))); +} + +/// `render` is checked before an edit: an object holding only `mode`, `shaded` or `wireframe`, and +/// only on the panel's four effects. +#[test] +fn render_settings_are_validated() { + for id in WIRE { + for good in [json!({}), json!({"render": {}}), json!({"render": {"mode": "shaded"}}), json!({"render": {"mode": "wireframe"}})] { + assert_eq!(crate::validate_three_d_params(id, &good), Ok(()), "{id} {good}"); + } + for bad in [ + json!({"render": "wireframe"}), + json!({"render": null}), + json!({"render": {"mode": "raytrace"}}), + json!({"render": {"mode": true}}), + json!({"render": {"mode": "wireframe", "quality": "high"}}), + ] { + let e = crate::validate_three_d_params(id, &bad).unwrap_err(); + assert_eq!(e, "`render` must be an object holding only `mode`, set to shaded or wireframe", "{id} {bad}"); + } + // The command check on the object refuses it too. + let n = solid_square(id, json!({})); + assert!(crate::validate_three_d(&n, id, &json!({"render": {"mode": "x"}})).is_err(), "{id}"); + } + for id in [super::super::CLASSIC_EXTRUDE, crate::CLASSIC_REVOLVE, super::super::CLASSIC_ROTATE] { + let e = crate::validate_three_d_params(id, &json!({"render": {"mode": "wireframe"}})).unwrap_err(); + assert!(e.contains("`render`"), "{id}: {e}"); + } +} + +/// A Revolve whose shaded surface is past the budget of paint copies (a gradient with many stops) is +/// refused shaded, but in wireframe mode, which draws only the black edges, it is accepted and drawn: +/// on the object and on a fill. +#[test] +fn wireframe_mode_is_checked_against_the_edges_it_draws() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let n = solid_square(crate::REVOLVE, json!({})); + let path = n.path_data().unwrap().clone(); + let faces = vectorcraft_three_d::revolve(&path, path.bounds().unwrap(), crate::revolve::revolve_options(&json!({}))).unwrap().len(); + let stops = crate::revolve::MAX_PAINT_COPIES / (faces * 4) / std::mem::size_of::() + 1; + let stops = (0..stops).map(|i| GradientStop::new(i as f32 / stops as f32, Color::BLACK)).collect(); + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let mut n = n; + n.appearance = Appearance::basic(gradient, Paint::None, 0.0).into(); + let shaded = json!({}); + let wire = json!({"render": {"mode": "wireframe"}}); + let refused = crate::validate_revolve(&n, &shaded).unwrap_err(); + assert!(refused.contains("simplify the gradient"), "{refused}"); + assert_eq!(crate::validate_revolve(&n, &wire), Ok(())); + n.appearance.effects.push(crate::new_effect(crate::REVOLVE, &wire).unwrap()); + assert!(leaves(&drawn(&n)).iter().all(|(_, filled, stroke)| !filled && stroke.is_some())); + // On the fill. + let fx = n.appearance.effects.remove(0); + let at = n.appearance.items.iter().position(|i| matches!(i, AppearanceItem::Fill(_))).unwrap(); + assert!(crate::revolve::validate_item_revolve(&n, &shaded, at, 0).is_err()); + assert_eq!(crate::revolve::validate_item_revolve(&n, &wire, at, 0), Ok(())); + n.appearance.items[at].effects_mut().push(fx); + assert!(leaves(&drawn(&n)).iter().all(|(_, filled, stroke)| !filled && stroke.is_some())); +} + +/// A 3D (Classic) effect ignores a `render` it does not take in a loaded file: it draws as before. +#[test] +fn a_classic_effect_is_not_drawn_as_a_render_wireframe() { + let mut n = solid_square(super::super::CLASSIC_EXTRUDE, json!({})); + n.appearance.effects[0].params["render"] = json!({"mode": "wireframe"}); + assert!(!renders_wireframe(&n.appearance.effects[0])); + assert!(leaves(&drawn(&n)).iter().any(|(_, filled, _)| *filled)); +} + #[test] fn an_object_without_a_visible_3d_effect_is_an_error() { let mut n = solid_square(super::super::EXTRUDE, json!({})); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 99b8e1ecdd..5cf923eb99 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -43,6 +43,7 @@ pub(crate) mod graph; mod halftone; pub mod help; pub mod importmatches; +mod info; pub(crate) mod inline; mod interactivity; mod intertwine; @@ -344,6 +345,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(docmenu::specs()); v.extend(variablecmds::specs()); v.extend(docinfo::specs()); + v.extend(info::specs()); v.extend(panelcmds::specs()); v.extend(artboardalign::specs()); v.extend(buildcmds::specs()); @@ -828,6 +830,7 @@ pub(crate) use edit::{settle_interaction, typing_in_progress}; pub(crate) use object::bounds_rewrite; pub(crate) use object::{keep_aligned_in, refresh_pixel_aligned}; pub use plugin_call::{Location, ParamsPlace, key_location, location_in, location_keys}; +pub use select::SIMILAR; pub(crate) use vectorcraft_doc::range::parse_range; /// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index fc87ad4fa2..256e585923 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -422,6 +422,15 @@ pub(crate) fn design_reference(s: &Session, p: &Value, cmd: &str) -> Result Result> { + if matches!(item, ItemTarget::Item { explicit: false, .. }) { + return Ok(None); + } + design_reference(s, p, cmd) +} + /// `ids` after `of` (see [`design_reference`]), when there is one. pub(crate) fn with_reference(of: Option, ids: &[NodeId]) -> Vec { of.into_iter().chain(ids.iter().copied()).collect() @@ -610,16 +619,23 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result> { /// ([`capture_series_changes`], [`super::graph::capture_label_changes`]). Nothing targeted is not /// an edit, and when `f` leaves every target as it was, the document and its undo history stay as /// they are. +/// +/// With `of` (see [`reference_of`]), an explicit aligned `item` is a row of the stack of `of`, which +/// is not among `ids`: it must be there and of the edited kind, each target edits the row equal to +/// it, a target without one is left as it is, and no target with one is an error. Returns the +/// targets `f` was aimed at, in the order of `ids`. +#[allow(clippy::too_many_arguments)] pub(crate) fn edit_items( s: &mut Session, ids: &[NodeId], item: ItemTarget, + of: Option, cmd: &str, label: &str, fill: bool, f: impl FnMut(&mut Node, Option) -> Result<()>, -) -> Result<()> { - edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| { +) -> Result> { + edit_items_then(s, ids, item, of, cmd, label, fill, f, |before, d, edited| { let series = capture_series_changes(before, d, edited); // The rows `f` edited are kept by `edit_items_then`. let labels = super::graph::capture_label_changes(before, d, edited, false); @@ -636,17 +652,29 @@ pub(crate) fn edit_items_then( s: &mut Session, ids: &[NodeId], item: ItemTarget, + of: Option, cmd: &str, label: &str, fill: bool, mut f: impl FnMut(&mut Node, Option) -> Result<()>, mut after: impl FnMut(&vectorcraft_doc::Document, &mut vectorcraft_doc::Document, &[NodeId]) -> Result, -) -> Result<()> { - if ids.is_empty() { - return Ok(()); - } +) -> Result> { + let art = of.and_then(|of| s.doc().ok()?.doc.node(of).map(|n| n.appearance.clone())); + let per = match (item, art) { + // Checked even when the art is the only object given, which leaves `ids` empty. + (ItemTarget::Item { index, explicit: true, aligned: true }, Some(art)) => { + item.resolve(&art, fill, cmd)?; + let per = item.per_target_of(s, of, ids); + if per.iter().all(Option::is_none) { + return Err(bad(cmd, format!("no appearance item {index}"))); + } + per + } + _ if ids.is_empty() => return Ok(Vec::new()), + _ => item.per_target(s, ids), + }; + let aimed: Vec = ids.iter().zip(&per).filter(|(_, item)| item.is_some()).map(|(id, _)| *id).collect(); let before = s.doc()?.doc.clone(); - let per = item.per_target(s, ids); super::overprint::edit_counted(s, label, |d| { let mut edited = Vec::new(); // The objects whose rows `f` edited: on type, no row means its characters. @@ -665,7 +693,13 @@ pub(crate) fn edit_items_then( let labels = super::graph::capture_label_rows(d, &rows); Ok(count_changed(d, &before, &edited) + usize::from(more || labels)) })?; - Ok(()) + Ok(aimed) +} + +/// The first of `ids` that [`edit_items`] aimed at `item` with `of` changes, and the item it changes there. Read it +/// before the edit: once edited, a row that was equal to a row of the art of `of` no longer is. +pub(crate) fn first_edited(s: &Session, item: ItemTarget, of: Option, ids: &[NodeId]) -> Option<(NodeId, ItemTarget)> { + ids.iter().zip(item.per_target_of(s, of, ids)).find_map(|(id, it)| Some((*id, it?))) } /// How many of `ids` differ in `d` from `before`. @@ -674,8 +708,8 @@ pub(crate) fn count_changed(d: &vectorcraft_doc::Document, before: &vectorcraft_ } /// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the -/// targeted item (on the object whose row it is, see [`ItemTarget::anchor`]), else `default`. A `stroke` other than true, false or -/// null is an error of command `cmd`. +/// targeted item (on the object whose row it is, see [`ItemTarget::anchor`] and [`reference_of`]), else `default`. A `stroke` other than +/// true, false or null is an error of command `cmd`. pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool, cmd: &str) -> Result { check_switches(p, &["stroke"], cmd)?; if let Some(b) = p.get("stroke").and_then(Value::as_bool) { @@ -683,8 +717,14 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo } let ItemTarget::Item { index, .. } = item else { return Ok(default) }; let d = &s.doc()?.doc; - let ids = drop_design_art(d, appearance_targets(s, p)?); - Ok(ids.get(item.anchor(s, &ids)).and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill())) + let holder = match reference_of(s, p, item, cmd)? { + Some(of) => Some(of), + None => { + let ids = drop_design_art(d, appearance_targets(s, p)?); + ids.get(item.anchor(s, &ids)).copied() + } + }; + Ok(holder.and_then(|id| d.node(id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill())) } /// The whole number of 0 or more that `key` holds, read with [`u64_req`] and [`index`]. diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 446634d21c..4e086569ae 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -32,7 +32,7 @@ use crate::DocState; pub fn specs() -> Vec { let mut v = vec![ - cmd!(query "clipboard.exportSvg", "Clipboard as SVG", [], None, "{} → {svg} the copied objects as standalone SVG (null when the clipboard is empty)", always, export_svg), + cmd!(query "clipboard.exportSvg", "Clipboard as SVG", [], None, "{} → {svg} the copied objects as standalone SVG (null when the clipboard is empty). Under automation read roots it is refused while the SVG would name a linked image's file outside them (an image whose pixels the clipboard doesn't hold; a relative path is followed from the document the objects came from while it is open)", always, export_svg), cmd!( query "clipboard.importSvg", "Load SVG into Clipboard", @@ -435,14 +435,37 @@ impl Clipboard { } impl Session { - /// The internal clipboard as standalone SVG (`None` when it is empty). + /// The internal clipboard as standalone SVG (`None` when it is empty, and when the SVG would name + /// a linked image's file the caller asking now may not be shown: [`Self::clipboard_svg_refused`]). pub fn clipboard_svg(&self) -> Option { + if self.clipboard_svg_refused() { + return None; + } self.clipboard.svg() } + + /// Would the internal clipboard's SVG name a linked image's file outside the read roots in + /// force? It names the file of an image whose pixels the clipboard doesn't hold. A relative + /// path is followed from the document the objects came from while it is open; otherwise it + /// counts as outside. + pub fn clipboard_svg_refused(&self) -> bool { + self.check_clipboard_svg("clipboard.exportSvg").is_err() + } + + /// [`Self::clipboard_svg_refused`] as `cmd`'s error, which names no file or folder. + fn check_clipboard_svg(&self, cmd: &str) -> Result<()> { + if crate::file_access::current().is_none() || !self.clipboard.has_art() { + return Ok(()); + } + let from = self.docs.iter().find(|d| Some(d.uid) == self.clipboard.source_doc); + let base = from.map_or_else(super::links::LinkBase::default, super::links::LinkBase::of); + super::fileio::check_images_in(cmd, "svg", &self.clipboard.to_document(), base, &json!({})) + } } fn export_svg(s: &mut Session, _: &Value) -> Result { - Ok(json!({ "svg": s.clipboard_svg() })) + s.check_clipboard_svg("clipboard.exportSvg")?; + Ok(json!({ "svg": s.clipboard.svg() })) } fn import_svg(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index 118773cfc8..42113c9578 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -14,7 +14,8 @@ use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_render::effects; use super::appearance::{ - ItemTarget, aligned, appearance_targets, design_reference, edit_targets, index_param, item_indices, item_target, item_target_at, with_reference, + ItemTarget, aligned, appearance_targets, design_reference, edit_targets, index_param, item_indices, item_target, item_target_at, reference_of, + with_reference, }; use super::expand::has_seeded; use super::rasterfx; @@ -336,15 +337,6 @@ pub(crate) fn effect_targets(s: &Session, p: &Value, item: ItemTarget, index: us /// have. const APPENDED: usize = usize::MAX; -/// The [`design_reference`] of an effect edit aimed at `item`: none for the Appearance panel's active row, which is a -/// row of the first selected object's stack. -fn reference_of(s: &Session, p: &Value, item: ItemTarget, cmd: &str) -> Result> { - if matches!(item, ItemTarget::Item { explicit: false, .. }) { - return Ok(None); - } - design_reference(s, p, cmd) -} - #[allow(clippy::too_many_arguments)] fn edit_effects( s: &mut Session, diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index ce919c7cc9..28cd71804a 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -125,9 +125,10 @@ mod commands; mod export_names; mod formats; +pub(crate) use access::check_images_in; #[cfg(not(target_arch = "wasm32"))] pub(crate) use access::list_dir; -pub use access::{check_linked_scripts, write_file_with, write_save_file, write_save_file_with}; +pub use access::{check_linked_images, check_linked_scripts, write_file_with, write_save_file, write_save_file_with}; #[cfg(all(test, not(target_arch = "wasm32")))] mod tests_readonly; diff --git a/crates/engine/src/cmd/fileio/access.rs b/crates/engine/src/cmd/fileio/access.rs index 203a2262b0..cb5b4e85cc 100644 --- a/crates/engine/src/cmd/fileio/access.rs +++ b/crates/engine/src/cmd/fileio/access.rs @@ -139,6 +139,49 @@ pub fn check_linked_scripts(cmd: &str, format: &str, st: &crate::DocState) -> Re Ok(()) } +/// The error for an SVG that would name a linked image's file outside the read roots. +pub(crate) const IMAGE_OUTSIDE: &str = "a linked image file is outside the read roots"; + +/// `cmd` may write `d`, the document an output of `st` writes (all of it, or the objects it +/// exports), in format `format` (a format id) with options `p`: any format but SVG and SVGZ, and +/// those too unless read roots are in force and the file would name a linked image's file outside +/// them (a relative path taken from `st`'s folder; a URL is not checked). An `` names the +/// file with SVG Options › Images set to Link, and with Embed when the document holds no pixels for +/// the image, for the images of the art each file draws (with chosen artboards, the art over each +/// one; hidden layers and objects only with `hiddenLayers`, template layers and the opacity-mask +/// and symbol editing layers never) and of the symbols, patterns and brushes that art uses; with +/// Preserve Editing Capabilities, the native document the file carries names every linked file, the relative paths kept of them +/// and each data set's image. The error names no file or folder. +pub fn check_linked_images(cmd: &str, format: &str, st: &crate::DocState, d: &vectorcraft_doc::Document, p: &Value) -> Result<()> { + check_images_in(cmd, format, d, crate::cmd::links::LinkBase::of(st), p) +} + +/// [`check_linked_images`] for `d`, a document whose links are looked for from `base` (the +/// objects on the clipboard, looked for from the document they came from). +pub(crate) fn check_images_in(cmd: &str, format: &str, d: &vectorcraft_doc::Document, base: crate::cmd::links::LinkBase, p: &Value) -> Result<()> { + if !matches!(format, "svg" | "svgz") || crate::file_access::current().is_none() { + return Ok(()); + } + // Options that don't read as the encoder's choices count as Link, Preserve Editing and hidden + // layers kept: the encoder refuses them anyway. + let (link, preserve, hidden) = match super::svg::options_map(p) { + Ok(m) => { + let on = |k: &str| m.get(k).is_some_and(|v| v.as_bool() != Some(false)); + (m.get("images").is_some_and(|v| v.as_str() != Some("embed")), on("preserveEditing"), on("hiddenLayers")) + } + Err(_) => (true, true, true), + }; + // The opacity-mask and symbol editing layers are never written. + let d = &*d.without_edit_modes(); + let names = |d: &vectorcraft_doc::Document| crate::cmd::links::svg_names_hidden_image(d, base, link, hidden); + if preserve && crate::cmd::links::names_hidden_file(d, base) + || names(d) && super::svg::drawn(d, p).is_none_or(|files| files.iter().any(|f| names(f))) + { + return Err(bad(cmd, IMAGE_OUTSIDE)); + } + Ok(()) +} + /// `path` checked for writing against the automation roots in force. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn may_write(path: &str) -> Result<()> { diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index 1710f57eb9..27024ef8eb 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "Serialize Document", [], None, - "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked)", + "{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked), or while the file would name a linked image's file outside them (images: link, preserveEditing, or an image whose pixels the document doesn't hold; a URL is not checked)", has_doc, export::serialize ), @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; pdf/ai: of the layers the file draws, without editing data, with a warning, and ai refuses pdfCompatible: false; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the art it plots, without placed images, unpainted paths and art at 0% opacity), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked). Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; pdf/ai: of the layers the file draws, without editing data, with a warning, and ai refuses pdfCompatible: false; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the art it plots, without placed images, unpainted paths and art at 0% opacity), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked), or while the file would name a linked image's file outside them (images: link, preserveEditing, or an image whose pixels the document doesn't hold; a URL is not checked). Never changes the document's path", has_doc, export::export ), @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Export Selection…", ["File"], None, - "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them, as document.export refuses them. In the app, with no params or {}, the save panel picks the file and its format's options dialog follows (Save PDF, SVG Options, the raster options, EPS Options, DXF Options, Text Export Options or an exporter plug-in's parameters; other formats are written at once), whose OK writes the selection: the call returns null while the dialog is open, and OK returns what file.exportAs returns, without format or bounds (Text Export Options: {path})", + "{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|hpgl|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them, or while the file would name a linked image's file outside them (an image among the selected objects), as document.export refuses them. In the app, with no params or {}, the save panel picks the file and its format's options dialog follows (Save PDF, SVG Options, the raster options, EPS Options, DXF Options, Text Export Options or an exporter plug-in's parameters; other formats are written at once), whose OK writes the selection: the call returns null while the dialog is open, and OK returns what file.exportAs returns, without format or bounds (Text Export Options: {path})", has_selection, export::export_selection ), @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboard?: index | artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), includeArtboardFill?: true (Include Artboard Fill: each artboard's background color behind its art; false leaves it out), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with for the document, for the artboard, asset, layer or object, from 1, #### for the counter zero-padded to the run of #, , , (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, ,