diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 3dd07351e..774e82439 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -31,6 +31,7 @@ pub use ids::{LAYER_COLORS, LayerColor, NodeId}; pub use kind::{ImageObject, MAX_BUFFERED_VALUES, NodeKind, with_buffered_value_budget}; pub use live_shape::{LiveShape, Scaling}; pub use opacity::{Knockout, OpacityMask, whiten}; +pub use ops::{flattens, keeps_gradient_fit}; use wire::NodeWire; pub use wire::{MAX_READ_NODES, with_node_budget}; diff --git a/crates/doc/src/node/live_shape.rs b/crates/doc/src/node/live_shape.rs index 93f2022e0..9ddc4e1bb 100644 --- a/crates/doc/src/node/live_shape.rs +++ b/crates/doc/src/node/live_shape.rs @@ -329,6 +329,10 @@ pub struct Scaling { /// Pattern fills and strokes move with the art (Transform Patterns; General › Transform /// Pattern Tiles is its default): their tiles transform too, instead of staying put. pub patterns: bool, + /// Gradients fitted to the object stay fitted under a transform that flattens it to a line or + /// a point (no area left), as a Transform effect leaves them, instead of being fixed to their + /// fit and flattened to one color with the object. + pub keep_fit_when_flat: bool, } impl Scaling { diff --git a/crates/doc/src/node/ops.rs b/crates/doc/src/node/ops.rs index 0af1d0d79..f908e50a1 100644 --- a/crates/doc/src/node/ops.rs +++ b/crates/doc/src/node/ops.rs @@ -431,7 +431,7 @@ impl Node { // Type and symbols in perspective keep looking the same, moved by `a`. self.transform_projection(a); // Refitting an unplaced gradient only reproduces moves and uniform scales. - if !keeps_gradient_fit(a) { + if !keeps_gradient_fit(a) && !(sc.keep_fit_when_flat && flattens(a)) { self.pin_gradients(); } let k = Scaling::factor(a); @@ -770,13 +770,24 @@ impl Node { } } -/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)? -fn keeps_gradient_fit(a: Affine) -> bool { +/// Is `a` a move plus a positive uniform scale (after which a refit gradient still matches)? The +/// terms are compared within 1e-12 of the scale (at least of 1). A scale of 0 or less, a turn, a +/// reflection and an uneven scale give false, and so does a map that flattens the object +/// ([`flattens`]), which callers that keep a fitted gradient there check apart. +pub fn keeps_gradient_fit(a: Affine) -> bool { let [m0, m1, m2, m3, _, _] = a.as_coeffs(); let eps = 1e-12 * m0.abs().max(1.0); m0 > 0.0 && m1.abs() <= eps && m2.abs() <= eps && (m0 - m3).abs() <= eps } +/// Does `a` flatten what it maps to a line or a point (its determinant 0, within 1e-12 of the +/// squares of its terms), or is it not finite? +pub fn flattens(a: Affine) -> bool { + let [m0, m1, m2, m3, _, _] = a.as_coeffs(); + let norm = m0 * m0 + m1 * m1 + m2 * m2 + m3 * m3; + !a.is_finite() || a.determinant().abs() <= 1e-12 * norm +} + impl Node { /// Whether a document point lies in this object's filled regions ([`Node::clip_shapes`]; true /// everywhere for objects without them, such as type, whose boxes stand in). diff --git a/crates/doc/src/node/tests.rs b/crates/doc/src/node/tests.rs index 43a8ac97b..335eb6312 100644 --- a/crates/doc/src/node/tests.rs +++ b/crates/doc/src/node/tests.rs @@ -221,7 +221,8 @@ fn assert_round_corners(n: &Node, back: Affine, r: f64) { } } -const KEEP_CORNERS: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true, patterns: false }; +const KEEP_CORNERS: Scaling = + Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: true, patterns: false, keep_fit_when_flat: false }; #[test] fn uneven_scales_keep_live_corners_circular() { diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index 4819727f9..7ecb869bb 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -210,6 +210,139 @@ fn baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bo assert_eq!(baked(&fitted, distort()).1.geom, None); } +/// A gradient fitted to the object turns, reflects and stretches with a Transform effect that a +/// refit would not reproduce: it is fixed to its fit on the geometry the effect takes, then mapped. +/// Moves and even scales leave it fitted. +#[test] +fn a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect() { + use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let fitted = |kind| Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(kind, stops.clone())))); + let geom = |start: (f64, f64), end: (f64, f64)| GradientGeom { start: start.into(), end: end.into(), aspect: 1.0, focal: None }; + // The geometry of the baked fill (`own`: the Transform effect is the fill's own effect). + let baked = |kind, effects: &[Effect], own: bool| { + let mut ap = Appearance::basic(fitted(kind), Paint::None, 0.0); + if own { + *ap.items[0].effects_mut() = effects.to_vec(); + } + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap); + if !own { + n.appearance.effects = effects.to_vec(); + } + let d = bake_document(&doc_with(n)).unwrap(); + let mut found = vec![]; + d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| { + if let Paint::Gradient(g) = c.appearance.fill_paint() { + found.push(g.geom); + } + }); + assert_eq!(found.len(), 1, "{effects:?}, own {own}"); + found[0] + }; + let t = |params| fx("distort.transform", params); + let offset = fx("path.offsetPath", json!({"offset": 10.0, "joins": "miter"})); + let corners = json!({"corners": [[1.0, 0.0], [2.0, 0.0], [2.0, 1.0], [1.0, 1.0]]}); + let linear = GradientKind::Linear; + let cases = [ + // A quarter turn about the center: the left to right ramp runs from the bottom up. + (linear, vec![t(json!({"rotate": 90.0}))], geom((50.0, 100.0), (50.0, 0.0))), + // Reflected left to right: the ramp runs from right to left. + (linear, vec![t(json!({"reflectX": true}))], geom((100.0, 50.0), (0.0, 50.0))), + // With Scale Strokes & Effects on, the same. + (linear, vec![t(json!({"rotate": 90.0, "strokes": true}))], geom((50.0, 100.0), (50.0, 0.0))), + // Offset Path grows the square to -10..110 first: the turn takes the fit across 120 pt. + (linear, vec![offset.clone(), t(json!({"rotate": 90.0}))], geom((50.0, 110.0), (50.0, -10.0))), + // Stretched to twice the width: the circle of radius 50 about the center becomes an + // ellipse 100 pt across and 50 pt tall. + (GradientKind::Radial, vec![t(json!({"scaleH": 200.0}))], GradientGeom { aspect: 0.5, ..geom((50.0, 50.0), (150.0, 50.0)) }), + // Turned, then moved one width right by Free Distort: the fixed gradient moves with it. + (linear, vec![t(json!({"rotate": 90.0})), fx("distort.freeDistort", corners)], geom((150.0, 100.0), (150.0, 0.0))), + ]; + for (kind, effects, want) in cases { + for own in [false, true] { + let what = format!("{kind:?} {effects:?}, own {own}"); + let got = baked(kind, &effects, own).unwrap_or_else(|| panic!("{what}: fixed to its fit")); + assert_geom_near(got, want, &what); + } + } + // A move and an even scale are what a refit gives: the gradient stays fitted. + for effects in [vec![t(json!({"moveH": 30.0}))], vec![t(json!({"scaleH": 150.0, "scaleV": 150.0})), offset]] { + for own in [false, true] { + assert_eq!(baked(linear, &effects, own), None, "{effects:?}, own {own}"); + } + } + // A stroke's gradient is fixed to its fit on the box grown by half its weight (4 pt), -2..102, + // with Scale Strokes & Effects off or on. + let stroked = |effects: &[Effect], own: bool| { + let mut ap = Appearance::basic(Paint::None, fitted(linear), 4.0); + let Some(stroke) = ap.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) else { panic!("a stroke") }; + if own { + *stroke.effects_mut() = effects.to_vec(); + } + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap); + if !own { + n.appearance.effects = effects.to_vec(); + } + let d = bake_document(&doc_with(n)).unwrap(); + let mut found = vec![]; + d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| { + for item in &c.appearance.items { + if let AppearanceItem::Stroke(s) = item + && let Paint::Gradient(g) = &s.paint + { + found.push(g.geom); + } + } + }); + assert_eq!(found.len(), 1, "{effects:?}, own {own}"); + found[0] + }; + for strokes in [false, true] { + for own in [false, true] { + let what = format!("stroke, strokes {strokes}, own {own}"); + let got = stroked(&[t(json!({"rotate": 90.0, "strokes": strokes}))], own).unwrap_or_else(|| panic!("{what}: fixed to its fit")); + assert_geom_near(got, geom((50.0, 102.0), (50.0, -2.0)), &what); + } + } +} + +/// A Transform effect that scales the geometry to nothing across leaves a fitted gradient fitted: +/// mapping it with the effect would flatten it to one color. +#[test] +fn a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted() { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let fitted = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + // With Scale Strokes & Effects or Transform Patterns on, and for each copy, as without them. + for (options, strokes) in [ + (json!({}), 1), + (json!({"strokes": true}), 1), + (json!({"patterns": true}), 1), + (json!({"copies": 2}), 3), + (json!({"strokes": true, "copies": 2}), 3), + (json!({"patterns": true, "copies": 2}), 3), + ] { + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::None, fitted.clone(), 4.0)); + let mut p = json!({"scaleH": 0.0, "rotate": 30.0}); + if let (Some(p), Some(o)) = (p.as_object_mut(), options.as_object()) { + p.extend(o.clone()); + } + n.appearance.effects = vec![fx("distort.transform", p)]; + let d = bake_document(&doc_with(n)).unwrap(); + let mut found = vec![]; + d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| { + for item in &c.appearance.items { + if let AppearanceItem::Stroke(s) = item + && let Paint::Gradient(g) = &s.paint + { + found.push(g.geom); + } + } + }); + assert_eq!(found, vec![None; strokes], "{options}"); + } +} + #[test] fn baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds() { use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 26c193b62..a8176880b 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -1,4 +1,4 @@ -use super::{CROP_MARKS, EXTRUDE, INFLATE, PATHFINDER_EFFECTS, RASTERIZE, REVOLVE, ROTATE, TRAP, Value, json, trap}; +use super::{CLASSIC_EXTRUDE, CROP_MARKS, EXTRUDE, INFLATE, PATHFINDER_EFFECTS, RASTERIZE, REVOLVE, ROTATE, TRAP, Value, json, trap}; /// Catalogue entry for one effect. #[derive(Clone, Debug, PartialEq)] @@ -80,6 +80,7 @@ fn lengths_of(id: &str) -> Lengths { "path.offsetPath" => always(&["offset"]), "threeD.revolve" => always(&["offset"]), "threeD.extrude" => always(&["depth", "bevelWidth", "bevelHeight"]), + "threeD.classicExtrude" => always(&["depth", "bevelHeight"]), "threeD.inflate" => always(&["depth"]), "path.outlineStroke" => always(&["width"]), "stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]), @@ -199,6 +200,13 @@ pub fn effect_catalog() -> Vec { "{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)}. 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}), ), + g( + CLASSIC_EXTRUDE, + "Extrude & Bevel (Classic)…", + &["Effect", "3D and Materials", "3D (Classic)"], + "{depth: pt (0..2000, 50), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelHeight: pt (1..100, 4; the bevel is as wide as it is tall), bevelInside: bool (false: added around the shape; true: carved into it), 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), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face, hidden ones included, and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), 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, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), drawHiddenFaces: bool (false; also draws the faces turned away, behind the others)}. The front face is the art, extruded backwards about the center of its bounds; a solid is built for the top fill and for each stroke, each in its own paint. The defaults are VectorCraft's own.", + json!({"depth":50.0,"cap":true,"bevel":"none","bevelHeight":4.0,"bevelInside":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","drawHiddenFaces":false}), + ), g( "convertToShape.rectangle", "Rectangle…", @@ -239,7 +247,7 @@ pub fn effect_catalog() -> Vec { "distort.transform", "Transform…", DT, - "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise), copies: 0..1000 (0; each copy transforms the last again; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", + "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}), ), g( diff --git a/crates/effects/src/geometry.rs b/crates/effects/src/geometry.rs index d561c90b9..eea263d48 100644 --- a/crates/effects/src/geometry.rs +++ b/crates/effects/src/geometry.rs @@ -174,22 +174,32 @@ pub fn apply_geometry_bez_carrying(effects: &[Effect], path: &BezPath, bounds: R (out.to_bezpath(), carry) } -/// How placed gradients follow geometry effects. The Transform effect without copies maps them as -/// it maps the object (with copies, `crate::reshape` gives each copy its own). Free Distort and -/// the Warp effects move and scale them from the object's bounds before the effect to its bounds -/// after it. For the other geometry effects it is empty. It does not change gradients fitted to -/// the object. +/// How gradients follow geometry effects. The Transform effect without copies maps placed +/// gradients as it maps the object (with copies, `crate::reshape` gives each copy its own). Free +/// Distort and the Warp effects move and scale them from the object's bounds before the effect to +/// its bounds after it. For the other geometry effects it is empty. A gradient fitted to the +/// object fits the geometry each effect makes, until a Transform effect turns, reflects or scales +/// it unevenly (a map after which a refit would not match the mapped gradient): it is fixed to its +/// fit on the geometry that effect takes, and then follows that effect and the ones after it as a +/// placed gradient does. #[derive(Clone, Debug, Default, PartialEq)] pub struct Carry(Vec); +/// Does `m` fix a gradient fitted to the object to its fit before mapping it? It does when a refit +/// would not reproduce the map (a turn, a reflection or an uneven scale) and `m` keeps area: a map +/// that flattens the geometry to a line (a scale of 0) leaves the gradient fitted, since mapping it +/// would flatten it to one color ([`vectorcraft_doc::node::flattens`]). +pub(crate) fn fixes_fit(m: vectorcraft_geom::Affine) -> bool { + !vectorcraft_doc::node::flattens(m) && !vectorcraft_doc::node::keeps_gradient_fit(m) +} + #[derive(Clone, Copy, Debug, PartialEq)] enum CarryStep { - Affine(vectorcraft_geom::Affine), + /// Map `m`; `from` is the geometric bounds of the geometry it takes, which a fitted gradient + /// fits before `m` moves it (none: fitted gradients stay fitted). + Affine { m: vectorcraft_geom::Affine, from: Option }, /// From box `from` to box `to` (strokes: their stroke-inflated boxes). - Boxes { - from: Rect, - to: Rect, - }, + Boxes { from: Rect, to: Rect }, } impl Carry { @@ -229,7 +239,7 @@ impl Carry { return Carry::default(); } let step = match id { - "distort.transform" => Some(CarryStep::Affine(distort::transform_map(b, p, seed).0)), + "distort.transform" => Some(CarryStep::Affine { m: distort::transform_map(b, p, seed).0, from }), _ => match (from, to) { (Some(from), Some(to)) if from != to && [to.x0, to.y0, to.x1, to.y1].iter().all(|v| v.is_finite()) => { Some(CarryStep::Boxes { from, to }) @@ -249,17 +259,32 @@ impl Carry { /// freeform gradient's own points included)? Callers check first, so an appearance shared with /// other objects is not copied for nothing. pub fn moves(&self, ap: &Appearance) -> bool { - !self.0.is_empty() && ap.has_positioned_gradient() + !self.0.is_empty() && (ap.has_positioned_gradient() || (self.pin().is_some() && ap.has_unplaced_gradient())) } - /// Move the placed gradients of `ap` along, through each step in turn. + /// The first step that fixes gradients fitted to the object: a map a refit would not + /// reproduce, on geometry whose bounds are known. + fn pin(&self) -> Option { + self.0.iter().position(|s| matches!(s, CarryStep::Affine { m, from: Some(_) } if fixes_fit(*m))) + } + + /// Move the gradients of `ap` along. Placed gradients take every step in turn; fitted ones + /// are fixed to their fit at the first step [`Self::pin`] finds and take it and the steps + /// after it. pub fn apply(&self, ap: &mut Appearance) { if !self.moves(ap) { return; } - for s in &self.0 { + // The steps move placed gradients only; fitted ones become placed at the pin. + let pin = self.pin(); + for (k, s) in self.0.iter().enumerate() { + if Some(k) == pin + && let CarryStep::Affine { from: Some(b), .. } = *s + { + ap.pin_gradients(b); + } match *s { - CarryStep::Affine(m) => ap.transform_gradients(m), + CarryStep::Affine { m, .. } => ap.transform_gradients(m), CarryStep::Boxes { from, to } => crate::reshape::follow_boxes(ap, from, to), } } diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 9c7e2a31a..1b876c6ff 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -598,6 +598,70 @@ fn a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills() { } } +/// A group's Transform effect that turns its art turns the gradients fitted to its paths, to the +/// group's own fill, to a nested group's own fill and to an image's and a symbol instance's own +/// fills: each is fixed to its fit on its own object first. A move leaves the members' gradients +/// fitted. +#[test] +fn a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill() { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + use vectorcraft_doc::ImageObject; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let fitted = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let square = |id: u64, x: f64| { + Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::basic(fitted.clone(), Paint::None, 0.0))) + }; + let mut image = Node::new( + NodeId(4), + NodeKind::Image(Box::new(ImageObject { + key: "k".into(), + width: 10, + height: 10, + xf: vectorcraft_geom::Affine::translate((30.0, 0.0)), + link: None, + placement: Default::default(), + pixels: None, + })), + ); + image.appearance = Appearance::basic(fitted.clone(), Paint::None, 0.0).into(); + // A symbol instance whose symbol is not in a document is measured as the 20 pt square about + // its origin: here 22.5..27.5 across and 2.5..7.5 down. + let at = vectorcraft_geom::Affine::translate((25.0, 5.0)) * vectorcraft_geom::Affine::scale(0.25); + let mut instance = Node::new(NodeId(6), NodeKind::SymbolInstance { symbol: "s".into(), xf: at }); + instance.appearance = Appearance::basic(fitted.clone(), Paint::None, 0.0).into(); + // Ends of each gradient by object id; none for a gradient still fitted. + let ends = |params: serde_json::Value| { + let mut inner = Node::group(NodeId(5), vec![square(2, 10.0)]); + inner.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(fitted.clone()))); + let mut g = Node::group(NodeId(10), vec![square(1, 0.0), Arc::new(inner), Arc::new(image.clone()), Arc::new(instance.clone())]); + g.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(fitted.clone()))); + g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); + let art = evaluate_container(None, &g).unwrap(); + let mut out = std::collections::BTreeMap::new(); + art.walk(&mut |c: &Node| { + if let Paint::Gradient(p) = c.appearance.fill_paint() { + out.insert(c.id.0, p.geom.map(|g| (g.start, g.end))); + } + }); + out + }; + let near = |a: Point, b: Point| a.distance(b) < 1e-6; + // A quarter turn about the group's center (20, 5): (x, 5) goes to (20, 5 + 20 - x), so a ramp + // from (x0, 5) to (x1, 5) runs from (20, 25 - x0) up to (20, 25 - x1). + let turned = ends(serde_json::json!({"rotate": 90})); + let want = [(1, 0.0, 10.0), (2, 10.0, 20.0), (5, 10.0, 20.0), (4, 30.0, 40.0), (6, 22.5, 27.5), (10, 0.0, 40.0)]; + for (id, x0, x1) in want { + let got = turned.get(&id).copied().flatten(); + let ok = got.is_some_and(|(s, e)| near(s, Point::new(20.0, 25.0 - x0)) && near(e, Point::new(20.0, 25.0 - x1))); + assert!(ok, "object {id}: {got:?} in {turned:?}"); + } + // The group's own fill is fixed to its fit on the art it paints, which the move took to 30..70. + let moved = ends(serde_json::json!({"moveH": 30})); + let group = moved.get(&10).copied().flatten(); + assert!(group.is_some_and(|(s, e)| near(s, Point::new(30.0, 5.0)) && near(e, Point::new(70.0, 5.0))), "{moved:?}"); + assert!(moved.len() == 6 && [1, 2, 4, 5, 6].iter().all(|id| moved[id].is_none()), "{moved:?}"); +} + #[test] fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() { use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 84c83d2e7..efd6d5ceb 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -85,8 +85,9 @@ pub use reshape::{ }; pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use solids::{ - EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, SolidMesh, THREE_D_TYPES, extrude_options, has_solid, inflate_options, is_three_d, solid_art, - three_d_meshes, three_d_meshes_of, three_d_params_as, validate_solid, validate_three_d, view_options, + CLASSIC_EXTRUDE, EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, + classic_shading, extrude_options, has_solid, inflate_options, is_three_d, solid_art, three_d_meshes, three_d_meshes_of, three_d_params_as, + validate_solid, validate_three_d, validate_three_d_params, view_options, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use svg_filter::SVG_FILTER; diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index ca39bc6cf..bf51244f4 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -708,7 +708,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b return; } if separate { - let made = copy_leaf(leaf, &path, m, copies, vectorcraft_doc::Scaling::default(), room); + let scaling = vectorcraft_doc::Scaling { keep_fit_when_flat: true, ..Default::default() }; + let made = copy_leaf(leaf, &path, m, copies, scaling, room); used = used.minus(before).plus(made); return; } @@ -718,8 +719,12 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b return; }; used = used.minus(before).plus(Budget::of(&out)); - if follows && !boxes && leaf.appearance.has_positioned_gradient() { - crate::Carry::of(&e.id, ¶ms, bounds, None, None, seed).apply(&mut leaf.appearance); + if follows && !boxes { + // A gradient fitted to the path fits the path's own bounds. + let carry = crate::Carry::of(&e.id, ¶ms, bounds, path.bounds(), None, seed); + if carry.moves(&leaf.appearance) { + carry.apply(&mut leaf.appearance); + } } set_geometry(leaf, out, rule); }); @@ -774,6 +779,8 @@ fn transform_leaf( strokes: options.strokes, effects: options.strokes.then_some(crate::scale_effect as fn(&mut Effect, f64)), patterns: options.patterns, + // A map that flattens the path leaves its fitted gradients fitted, as `fixes_fit` does. + keep_fit_when_flat: true, ..Default::default() }; if !options.objects { @@ -1052,7 +1059,7 @@ fn is_member_group(n: &Node) -> bool { } /// A group below the art that [`is_member_group`] takes and whose own fills or strokes hold a -/// placed gradient: the child indices that lead to it from the art, its geometric bounds, and +/// gradient: the child indices that lead to it from the art, its geometric bounds, and /// whether a path below it that its bounds measure kept its shape. type GroupMark = (Vec, Option, bool); @@ -1069,7 +1076,7 @@ fn group_marks(n: &Node, at: &mut Vec, kept: &HashSet, out: &mut for (i, c) in children.iter().enumerate() { at.push(i); let (b, h) = group_marks(c, at, kept, out); - if is_member_group(c) && c.appearance.has_positioned_gradient() { + if is_member_group(c) && (c.appearance.has_positioned_gradient() || c.appearance.has_unplaced_gradient()) { out.push((at.clone(), b, h)); } at.pop(); @@ -1223,6 +1230,10 @@ fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) { let src = (**c).clone(); let placed = |xf: Affine| { let mut one = src.clone(); + // Gradients fitted to it are fixed to their fit first when a refit would not match. + if crate::geometry::fixes_fit(xf) { + one.pin_gradients(); + } match &mut one.kind { NodeKind::Image(im) => im.xf = xf * im.xf, NodeKind::SymbolInstance { xf: at, .. } => *at = xf * *at, diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 946c6532b..b895aee3a 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -66,19 +66,25 @@ pub fn revolve_options(p: &Value) -> Revolve { /// Validate before a command mutates the document. Loading external files is still bounded /// by the evaluator; unsupported source kinds remain unchanged when rendered. pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> { - crate::lighting::validate(p)?; + validate_revolve_params(p)?; let path = profile(n).ok_or("Revolve currently requires a path or compound path. Select a profile drawn with the Pen tool.")?; let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?; if !path.subpaths.iter().any(|sp| sp.anchors.len() >= 2) { return Err("Revolve requires at least two anchors".into()); } + vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ()) +} + +/// Check Revolve's parameter values `p` alone: its lights, `edge` and `cap`. +pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> { + crate::lighting::validate(p)?; if p.get("edge").is_some_and(|v| v.as_str() != Some("left") && v.as_str() != Some("right")) { return Err("Revolve edge must be left or right".into()); } if p.get("cap").is_some_and(|v| v.as_bool().is_none()) { return Err("Revolve cap must be a boolean".into()); } - vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ()) + Ok(()) } fn profile(n: &Node) -> Option { diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 6f8eac9be..3390cc71f 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -1,5 +1,5 @@ -//! Extrude & Bevel, Inflate and Rotate as live, shaded vector art, shared by the canvas and every -//! exporter. Like Revolve, the source path stays editable and the effect draws its 3D faces. +//! Extrude & Bevel, Inflate, Rotate and Extrude & Bevel (Classic) as live, shaded vector art, +//! shared by the canvas and every exporter. Like Revolve, the source path stays editable and the effect draws its 3D faces. use std::sync::Arc; use serde_json::Value; @@ -14,11 +14,14 @@ use vectorcraft_doc::color::Color; use crate::util::{flag, num, text}; use crate::{GeomContext, apply_geometry_with, is_geometry}; +mod classic; +pub use classic::{CLASSIC_EXTRUDE, SHADING_COLORS, classic_extrude_options, classic_shading}; + pub const EXTRUDE: &str = "threeD.extrude"; pub const INFLATE: &str = "threeD.inflate"; pub const ROTATE: &str = "threeD.rotate"; /// The 3D effects this module draws (Revolve has its own). -pub const SOLIDS: [&str; 3] = [EXTRUDE, INFLATE, ROTATE]; +pub const SOLIDS: [&str; 4] = [EXTRUDE, INFLATE, ROTATE, CLASSIC_EXTRUDE]; /// Is `id` one of the 3D and Materials effects (Revolve included)? pub fn is_three_d(id: &str) -> bool { @@ -119,6 +122,7 @@ pub fn extrude_options(p: &Value) -> Extrude { inside: flag(p, "bevelInside", false), both_sides: flag(p, "bevelBothSides", false), }), + hidden: false, } } @@ -172,21 +176,32 @@ fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> let view = view_options(p); match id { EXTRUDE => vectorcraft_three_d::extrude(path, rule, bounds, extrude_options(p), view), + CLASSIC_EXTRUDE => vectorcraft_three_d::extrude(path, rule, bounds, classic_extrude_options(p), view), INFLATE => vectorcraft_three_d::inflate(path, rule, bounds, inflate_options(p), view), _ => vectorcraft_three_d::plane(path, rule, bounds, view), } } -/// Validate before a command changes the document; the evaluator bounds loaded files too. -pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> { +/// Check solid `id`'s parameter values `p` alone: the part of [`validate_solid`] that does not +/// need the object. +fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> { + if id == CLASSIC_EXTRUDE { + classic::validate(p)?; + } crate::lighting::validate(p)?; - let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…')); if let Some(v) = p.get("bevel") && !v.as_str().is_some_and(|s| s == "none" || BevelShape::parse(s).is_some()) { let names: Vec<_> = BevelShape::ALL.iter().map(|b| b.id()).collect(); return Err(format!("bevel must be none or one of {}", names.join(", "))); } + Ok(()) +} + +/// Validate before a command changes the document; the evaluator bounds loaded files too. +pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> { + validate_solid_params(id, p)?; + let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…')); if is_container(n) { let shapes = members(n, label)?; let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?; @@ -273,6 +288,13 @@ pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> { if id == crate::REVOLVE { crate::validate_revolve(n, p) } else { validate_solid(n, id, p) } } +/// Are 3D effect `id`'s parameter values `p` valid? The checks of [`validate_three_d`] that do not +/// build the art, for an edit that hides the effect: hidden, it draws nothing, so its object does +/// not need to fit. +pub fn validate_three_d_params(id: &str, p: &Value) -> Result<(), String> { + if id == crate::REVOLVE { crate::revolve::validate_revolve_params(p) } else { validate_solid_params(id, p) } +} + /// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and /// blend mode (so overlapping facets don't add up their transparency). fn surface(n: &Node, facets: Vec, paint: &Paint, opacity: f32, blend: BlendMode, colored: bool) -> Node { @@ -384,7 +406,8 @@ fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) { fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> { let b = union_bounds(shapes)?; let p = crate::merged_params(&fx.id, &fx.params); - let colored = view_options(&p).has_colored_lights(); + let classic = fx.id == CLASSIC_EXTRUDE; + let colored = !classic && view_options(&p).has_colored_lights(); if fx.id == ROTATE { // Every part turns about the centre of the object's own bounds (a group's members about // the centre of theirs), so they stay together. @@ -432,7 +455,8 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> .map(|(item, member, f)| { let depths: Vec = f.iter().map(|f| f.depth).collect(); let (paint, opacity, blend) = paint_of(item, member); - (depths, surface(n, f, &paint, opacity, blend, colored)) + let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) }; + (depths, art) }) .collect(); // One order takes a plain group of faces from each solid, one face for each facet. With diff --git a/crates/effects/src/solids/classic.rs b/crates/effects/src/solids/classic.rs new file mode 100644 index 000000000..730fb8e53 --- /dev/null +++ b/crates/effects/src/solids/classic.rs @@ -0,0 +1,220 @@ +//! Extrude & Bevel (Classic): the solid Extrude & Bevel builds from a path, drawn with the classic +//! surfaces (Wireframe, No Shading, Diffuse and Plastic), their highlight, blend steps and shading +//! color. +use std::sync::Arc; + +use serde_json::Value; +use vectorcraft_doc::color::{BlendMode, Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, StrokeLayer}; +use vectorcraft_geom::Rect; +use vectorcraft_three_d::{Bevel, BevelShape, ClassicShading, ClassicSurface, Extrude, Facet, Light}; + +use crate::util::{flag, num, text}; + +pub const CLASSIC_EXTRUDE: &str = "threeD.classicExtrude"; + +/// The width of a Wireframe edge, in points. +const WIRE: f64 = 0.5; + +/// The Shading Color choices: black, a custom color, or none. +pub const SHADING_COLORS: [&str; 3] = ["black", "custom", "none"]; + +/// The surface settings of Extrude & Bevel (Classic)'s parameters. +pub fn classic_shading(p: &Value) -> ClassicShading { + let d = ClassicShading::default(); + ClassicShading { + surface: ClassicSurface::parse(text(p, "surface", "plastic")).unwrap_or_default(), + ambient: num(p, "ambient", d.ambient * 100.0) / 100.0, + highlight_intensity: num(p, "highlightIntensity", d.highlight_intensity * 100.0) / 100.0, + highlight_size: num(p, "highlightSize", d.highlight_size * 100.0) / 100.0, + blend_steps: num(p, "blendSteps", f64::from(d.blend_steps)).round().clamp(1.0, 256.0) as u32, + } +} + +/// The solid of Extrude & Bevel (Classic)'s parameters: its depth, cap and bevel (as tall as it is +/// wide, added around the shape or carved into it), with the faces turned away from the viewer kept +/// for Draw Hidden Faces and for the Wireframe surface. +pub fn classic_extrude_options(p: &Value) -> Extrude { + let height = num(p, "bevelHeight", 4.0); + Extrude { + depth: num(p, "depth", 50.0), + cap: flag(p, "cap", true), + bevel: BevelShape::parse(text(p, "bevel", "none")).map(|shape| Bevel { + shape, + width: height, + height, + inside: flag(p, "bevelInside", false), + ..Bevel::default() + }), + hidden: flag(p, "drawHiddenFaces", false) || classic_shading(p).surface == ClassicSurface::Wireframe, + ..Extrude::default() + } +} + +/// The color faces darken toward: black, the custom color, or none (faces keep their paint and only +/// the highlight lightens them). +fn shading_color(p: &Value) -> Option { + match text(p, "shadingColor", "black") { + "none" => None, + "custom" => Some(p.get("shadingCustom").and_then(Value::as_str).and_then(parse_hex).unwrap_or(Color::BLACK)), + _ => Some(Color::BLACK), + } +} + +fn parse_hex(s: &str) -> Option { + (s.len() == 7 && s.starts_with('#') && s.bytes().skip(1).all(|b| b.is_ascii_hexdigit())).then(|| Color::from_hex(s)).flatten() +} + +/// The lights: the `lights` list when it is given, else the one light of `lightRotation`, +/// `lightHeight`, `lightBehind` and `lightIntensity`. Each is its direction toward the light in view +/// space and its intensity (0..1). +fn lights(p: &Value) -> Vec<([f64; 3], f64)> { + if let Some(list) = crate::lighting::lights(p) { + return list.iter().map(|l| (l.direction(), l.intensity)).collect(); + } + let light = Light { + rotation: num(p, "lightRotation", vectorcraft_three_d::DEFAULT_LIGHT.0), + height: num(p, "lightHeight", vectorcraft_three_d::DEFAULT_LIGHT.1), + behind: flag(p, "lightBehind", false), + ..Light::default() + }; + vec![(light.direction(), num(p, "lightIntensity", 100.0) / 100.0)] +} + +/// The numeric parameters: (key, lowest, highest, whole numbers only). +const RANGES: [(&str, f64, f64, bool); 13] = [ + ("depth", 0.0, 2000.0, false), + ("bevelHeight", 1.0, 100.0, false), + ("rotationX", -180.0, 180.0, false), + ("rotationY", -180.0, 180.0, false), + ("rotationZ", -180.0, 180.0, false), + ("perspectiveAngle", 0.0, 160.0, false), + ("lightRotation", -180.0, 180.0, false), + ("lightHeight", 0.0, 90.0, false), + ("lightIntensity", 0.0, 100.0, false), + ("ambient", 0.0, 100.0, false), + ("highlightIntensity", 0.0, 100.0, false), + ("highlightSize", 0.0, 100.0, false), + ("blendSteps", 1.0, 256.0, true), +]; + +const FLAGS: [&str; 4] = ["cap", "bevelInside", "lightBehind", "drawHiddenFaces"]; + +/// Check Extrude & Bevel (Classic)'s parameters `p` (its defaults filled in): every key is one of +/// its parameters, numbers are in range, flags are true or false, and `surface`, `shadingColor` and +/// `shadingCustom` name a surface, a shading color and a #rrggbb color. +pub fn validate(p: &Value) -> Result<(), String> { + let Some(map) = p.as_object() else { return Ok(()) }; + let defaults = crate::default_params(CLASSIC_EXTRUDE).unwrap_or(Value::Null); + let known: Vec<&str> = defaults.as_object().map(|m| m.keys().map(String::as_str).collect()).unwrap_or_default(); + for key in map.keys() { + if !known.contains(&key.as_str()) { + let keys = known.join(", "); + return Err(format!("unknown parameter `{key}`; the command takes: {keys}")); + } + } + for (key, min, max, whole) in RANGES { + let Some(v) = map.get(key) else { continue }; + let Some(x) = v.as_f64() else { return Err(format!("`{key}` must be a number")) }; + if whole && (x.fract() != 0.0 || !(min..=max).contains(&x)) { + return Err(format!("`{key}` must be a whole number between {min} and {max} (got {x})")); + } + if !(min..=max).contains(&x) { + return Err(format!("`{key}` must be between {min} and {max} (got {x})")); + } + } + for key in FLAGS { + if map.get(key).is_some_and(|v| !v.is_boolean()) { + return Err(format!("`{key}` must be true or false")); + } + } + let one_of = |key: &str, ids: &[&str]| -> Result<(), String> { + match map.get(key) { + Some(v) if !v.as_str().is_some_and(|s| ids.contains(&s)) => Err(format!("`{key}` must be one of {}", ids.join(", "))), + _ => Ok(()), + } + }; + let surfaces: Vec<&str> = ClassicSurface::ALL.iter().map(|s| s.id()).collect(); + one_of("surface", &surfaces)?; + one_of("shadingColor", &SHADING_COLORS)?; + let key = "shadingCustom"; + if map.get(key).is_some_and(|v| !v.as_str().is_some_and(|s| parse_hex(s).is_some())) { + return Err(format!("`{key}` must be a #rrggbb color")); + } + Ok(()) +} + +/// `c` with tone `tone` (0 the shading color, 1 the color itself) and highlight `spec` (toward +/// white) on top. +fn shaded(c: Color, tone: f64, spec: f64, shade: Option) -> Color { + let base = c.to_rgb(); + let dark = shade.map_or(base, |s| s.to_rgb()); + let (tone, spec) = (tone as f32, spec as f32); + let [r, g, b] = [0, 1, 2].map(|i| { + let mixed = dark[i] + (base[i] - dark[i]) * tone; + (mixed + (1.0 - mixed) * spec).clamp(0.0, 1.0) + }); + Color::rgb(r, g, b) +} + +/// One solid's faces in paint `paint`, as a group with the paint's opacity and blend mode, one child +/// for each facet: the edges of the faces on Wireframe, else each face filled in its paint shaded by +/// its tone and highlight. A gradient with transparent stops is shaded opaque and then masked by +/// its own transparency, so the shading follows the gradient's coverage. +pub(super) fn surface(n: &Node, facets: Vec, source: &Paint, opacity: f32, blend: BlendMode, p: &Value) -> Node { + let shading = classic_shading(p); + let lights = lights(p); + let shade = shading_color(p); + let wire = shading.surface == ClassicSurface::Wireframe; + let (paint, mask) = if wire { (source.clone(), None) } else { crate::lighting::opaque_paint(source) }; + let paint = &paint; + let children = facets + .into_iter() + .map(|face| { + if wire { + let mut edge = StrokeLayer::new(paint.clone(), WIRE); + edge.join = vectorcraft_doc::LineJoin::Round; + return Arc::new(Node::path(n.id, face.path(), Appearance { items: vec![AppearanceItem::Stroke(edge)], ..Default::default() })); + } + let (tone, spec) = shading.tone(face.normal, &lights); + // A narrow stroke of the face's own paint closes the hairline seams between faces. + let seam = (face.thickness() / 2.0).clamp(0.05, 0.75); + let items = match paint.color() { + // Unshaded (No Shading, no shading color, or a face in full light) with no + // highlight: the paint itself, its color space and swatch kept. + Some(_) if (tone >= 1.0 || shade.is_none()) && spec <= 0.0 => { + vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(StrokeLayer::new(paint.clone(), seam))] + } + Some(c) => { + let fill = Paint::solid(shaded(c, tone, spec, shade)); + vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(StrokeLayer::new(fill, seam))] + } + // Gradients and patterns keep their paint, darkened by a layer of the shading color + // and lightened by one of white. As with the other 3D effects' pattern shadow, the + // layers cover the whole face, transparent pattern cells included. + None => { + let mut items = + vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(StrokeLayer::new(paint.clone(), seam))]; + for (color, share) in [(shade, 1.0 - tone), (Some(Color::WHITE), spec)] { + if let Some(color) = color.filter(|_| share > 0.0) { + let mut layer = FillLayer::new(Paint::solid(color)); + layer.opacity = share as f32; + items.push(AppearanceItem::Fill(layer)); + } + } + items + } + }; + Arc::new(Node::path(n.id, face.path(), Appearance { items, ..Default::default() })) + }) + .collect(); + let mut group = Node::group(n.id, children); + group.opacity = opacity; + group.blend = blend; + if let Some(mask) = mask { + let bounds = group.visual_bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0); + let coverage = Node::path(n.id, vectorcraft_geom::shapes::rectangle(bounds), Appearance::basic(mask, Paint::None, 0.0)); + group.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(coverage, true))); + } + group +} diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index a9997bb5d..461f5610d 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -45,10 +45,15 @@ fn every_solid_draws_shaded_faces_and_leaves_the_source_editable() { assert_eq!(n.path_data(), Some(&source), "{id}"); assert!(!faces(&art).is_empty(), "{id}"); // The faces are the fill's red or the stroke's black, shaded: never brighter, never another - // hue. + // hue. The classic Plastic surface, its default, also lightens a face toward white, which + // scales every channel's distance from white by the same factor and so keeps r - g to + // r - b at 6 to 7. for f in faces(&art) { let [r, g, b] = fill_rgb(f); - assert!(r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4 || id == ROTATE, "{id}: {r} {g} {b}"); + let shaded = r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4; + let highlighted = + id == CLASSIC_EXTRUDE && [r, g, b].iter().all(|c| (0.0..=1.0).contains(c)) && ((r - g) * 7.0 - (r - b) * 6.0).abs() < 1e-4; + assert!(shaded || highlighted || id == ROTATE, "{id}: {r} {g} {b}"); } // The art follows the source when it moves. let before = art.visual_bounds().unwrap(); @@ -595,3 +600,160 @@ fn a_3d_effect_on_a_group_turns_every_member_about_one_shared_axis() { assert!(err.contains("clipping groups"), "{err}"); assert!(solid_art(&g).is_none(), "drawn as the group it is"); } + +/// The red square without its stroke, with Extrude & Bevel (Classic) and `params`. +fn classic(params: Value) -> Node { + let mut n = square_node(); + n.appearance.items.retain(|i| matches!(i, AppearanceItem::Fill(_))); + n.appearance.effects.push(fx(CLASSIC_EXTRUDE, params)); + n +} + +fn classic_faces(params: Value) -> Vec { + let art = solid_art(&classic(params)).unwrap(); + faces(&art).into_iter().cloned().collect() +} + +fn close3(a: [f32; 3], b: [f32; 3]) -> bool { + a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-4) +} + +#[test] +fn classic_surfaces_draw_edges_flat_paint_soft_light_and_a_highlight() { + // Wireframe: the edges of every face, the hidden ones too, in the paint; no fills. + let wire = classic_faces(json!({"surface": "wireframe"})); + let plastic = classic_faces(json!({})); + assert_eq!(plastic.len(), 3, "a box shows three faces"); + assert_eq!(wire.len(), 6, "and has six"); + for f in &wire { + let [AppearanceItem::Stroke(s)] = &f.appearance.items[..] else { panic!("a stroke alone: {:?}", f.appearance.items) }; + assert_eq!(s.paint, Paint::solid(RED)); + } + // No Shading: every face in the art's own paint, a CMYK color kept as it is. + assert!(classic_faces(json!({"surface": "none"})).iter().all(|f| close3(fill_rgb(f), RED.to_rgb()))); + let cmyk = Paint::solid(Color::Cmyk { c: 0.1, m: 0.9, y: 0.8, k: 0.0 }); + let mut n = classic(json!({"surface": "none"})); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.paint = cmyk.clone(); + } + for f in faces(&solid_art(&n).unwrap()) { + let AppearanceItem::Fill(fill) = &f.appearance.items[0] else { panic!("a fill first") }; + assert_eq!(fill.paint, cmyk); + } + // Diffuse: the red darkened toward black by how far each face turns from the light, so the + // faces differ but keep their hue. + let diffuse: Vec<[f32; 3]> = classic_faces(json!({"surface": "diffuse"})).iter().map(fill_rgb).collect(); + assert!(diffuse.iter().all(|[r, g, b]| *r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4), "{diffuse:?}"); + assert!(diffuse.windows(2).any(|w| !close3(w[0], w[1])), "{diffuse:?}"); + // Plastic: the same faces, one of them lightened toward white by the highlight. + let shiny: Vec<[f32; 3]> = + classic_faces(json!({"highlightIntensity": 100, "highlightSize": 100, "lightHeight": 60})).iter().map(fill_rgb).collect(); + let soft: Vec<[f32; 3]> = classic_faces(json!({"surface": "diffuse", "lightHeight": 60})).iter().map(fill_rgb).collect(); + assert!(shiny.iter().zip(&soft).any(|(s, d)| s[1] > d[1] + 0.01), "{shiny:?} {soft:?}"); + assert!(shiny.iter().zip(&soft).all(|(s, d)| s[1] >= d[1] - 1e-4)); + // Without highlight, Plastic shades as Diffuse. + let dull: Vec<[f32; 3]> = classic_faces(json!({"highlightIntensity": 0, "lightHeight": 60})).iter().map(fill_rgb).collect(); + assert!(dull.iter().zip(&soft).all(|(a, b)| close3(*a, *b))); +} + +#[test] +fn classic_shading_color_blend_steps_and_lights() { + let base = json!({"surface": "diffuse", "ambient": 20}); + let with = |extra: Value| { + let mut p = base.clone(); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + classic_faces(p).iter().map(fill_rgb).collect::>() + }; + // A custom shading color darkens toward that color; None keeps the paint. + assert!(with(json!({"shadingColor": "custom", "shadingCustom": "#0000ff"})).iter().any(|c| c[2] > c[0] / 8.0 + 0.01)); + assert!(with(json!({"shadingColor": "none"})).iter().all(|c| close3(*c, RED.to_rgb()))); + // One blend step: every face fully lit or fully in shadow. + let stepped = with(json!({"blendSteps": 1, "ambient": 0})); + assert!(stepped.iter().all(|c| close3(*c, RED.to_rgb()) || close3(*c, [0.0; 3])), "{stepped:?}"); + // No light: the ambient share of the red. Two lights add up. + let dark = with(json!({"lightIntensity": 0, "blendSteps": 256})); + assert!(dark.iter().all(|c| (c[0] - 0.8 * 0.2).abs() < 0.01), "{dark:?}"); + let one = with(json!({"lights": [{"intensity": 30}], "blendSteps": 256})); + let two = with(json!({"lights": [{"intensity": 30}, {"intensity": 30}], "blendSteps": 256})); + assert!(one.iter().zip(&two).all(|(a, b)| b[0] >= a[0] - 1e-4) && one.iter().zip(&two).any(|(a, b)| b[0] > a[0] + 0.01)); + // Draw Hidden Faces keeps the faces turned away, drawn first. + assert_eq!(with(json!({"drawHiddenFaces": true})).len(), 6); +} + +#[test] +fn classic_bevel_height_extent_and_cap_shape_the_solid() { + let bounds = |p: Value| solid_art(&classic(p)).unwrap().visual_bounds().unwrap(); + let front = json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "bevel": "classic", "bevelHeight": 10}); + let out = bounds(front.clone()); + let mut inside = front.clone(); + inside["bevelInside"] = json!(true); + // Straight on, a bevel added around the shape widens it; one carved into it keeps its size. + assert!(out.width() > 100.0 + 15.0, "{out:?}"); + let carved = bounds(inside); + assert!((carved.width() - 100.0).abs() < 2.0, "{carved:?}"); + // Hollow: no caps, so the walls seen from inside show too. + assert!(classic_faces(json!({"cap": false})).len() > classic_faces(json!({})).len()); + // The depth sets how far back the solid goes. + let side = json!({"rotationX": 0, "rotationY": 90, "rotationZ": 0}); + let mut deep = side.clone(); + deep["depth"] = json!(120); + assert!((bounds(deep).width() - 120.0).abs() < 1.0); +} + +/// A gradient fill with a transparent stop is shaded opaque and masked by its own transparency, so +/// the shading does not cover the transparent part; an opaque gradient needs no mask. +#[test] +fn classic_shading_keeps_a_gradients_transparency() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + for (end, masked) in [(0.25, true), (1.0, false)] { + let mut stop = GradientStop::new(1.0, RED); + stop.opacity = end; + let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), stop])); + let mut n = classic(json!({})); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.paint = Paint::Gradient(Box::new(gradient)); + } + let art = solid_art(&n).unwrap(); + let surface = &art.children().unwrap()[0]; + assert_eq!(surface.extras().mask.is_some(), masked, "{end}"); + let mut opaque = true; + surface.walk(&mut |c| { + for item in &c.appearance.items { + if let AppearanceItem::Fill(f) = item + && let Paint::Gradient(g) = &f.paint + { + opaque &= g.gradient.stops.iter().all(|s| s.opacity == 1.0); + } + } + }); + assert!(opaque, "{end}: the faces are drawn opaque"); + } +} + +#[test] +fn classic_parameters_are_checked() { + let n = classic(json!({})); + let check = |p: Value| validate_solid(&n, CLASSIC_EXTRUDE, &crate::merged_params(CLASSIC_EXTRUDE, &p)); + assert!(check(json!({})).is_ok()); + assert!(check(json!({"surface": "wireframe", "shadingColor": "custom", "shadingCustom": "#12aBcd", "blendSteps": 256, "bevel": "ogee"})).is_ok()); + for (p, says) in [ + (json!({"surface": "glossy"}), "`surface` must be one of wireframe, none, diffuse, plastic"), + (json!({"shadingColor": "red"}), "`shadingColor` must be one of black, custom, none"), + (json!({"shadingCustom": "blue"}), "`shadingCustom` must be a #rrggbb color"), + (json!({"shadingCustom": "#fff"}), "`shadingCustom` must be a #rrggbb color"), + (json!({"depth": 2001}), "`depth` must be between 0 and 2000 (got 2001)"), + (json!({"bevelHeight": 0}), "`bevelHeight` must be between 1 and 100 (got 0)"), + (json!({"ambient": -1}), "`ambient` must be between 0 and 100 (got -1)"), + (json!({"blendSteps": 2.5}), "`blendSteps` must be a whole number between 1 and 256 (got 2.5)"), + (json!({"blendSteps": 0}), "`blendSteps` must be a whole number between 1 and 256 (got 0)"), + (json!({"highlightSize": "big"}), "`highlightSize` must be a number"), + (json!({"cap": "yes"}), "`cap` must be true or false"), + (json!({"drawHiddenFaces": 1}), "`drawHiddenFaces` must be true or false"), + (json!({"bevel": "fancy"}), "bevel must be none or one of"), + (json!({"twist": 10}), "unknown parameter `twist`"), + (json!({"lights": []}), "lights must contain one to ten lights"), + ] { + let err = check(p.clone()).unwrap_err(); + assert!(err.starts_with(says), "{p}: {err}"); + } +} diff --git a/crates/engine/src/cmd/compositefonts.rs b/crates/engine/src/cmd/compositefonts.rs index 2b356ab85..3925612a1 100644 --- a/crates/engine/src/cmd/compositefonts.rs +++ b/crates/engine/src/cmd/compositefonts.rs @@ -71,24 +71,39 @@ pub fn specs() -> Vec { /// other open documents (or an earlier active document) defined. Run after each command and when /// another document takes the front, so an undo, a revert, an opened or closed file or a document /// switch draws type in the active document's own composite fonts, and type naming one it does -/// not define shows as missing. Whether a composite font was added, changed or removed. -pub(crate) fn sync(s: &mut Session) -> bool { +/// not define shows as missing. The lowercased names of the composite fonts added, changed or +/// removed. +pub(crate) fn sync(s: &mut Session) -> Vec { let db = FontDb::global(); let mut names = Vec::new(); - let mut changed = false; + let mut changed = Vec::new(); for f in s.active().map(|d| d.doc.composite_fonts.to_vec()).unwrap_or_default() { - changed |= db.set_composite_font(&f); - names.push(f.name.to_lowercase()); + let name = f.name.to_lowercase(); + if db.set_composite_font(&f) { + changed.push(name.clone()); + } + names.push(name); } let others = s.docs.iter().flat_map(|d| d.doc.composite_fonts.iter().map(|f| f.name.to_lowercase())); let stale: Vec = s.composite_names.iter().cloned().chain(others).filter(|n| !names.contains(n)).collect(); for gone in stale { - changed |= db.remove_composite_font(&gone); + if db.remove_composite_font(&gone) { + changed.push(gone); + } } + s.composite_names_registered.extend(names.iter().chain(&changed).cloned()); s.composite_names = names; changed } +/// The lowercased names of the composite fonts the open documents define, the font database holds +/// and the session has registered before: the names type may have been laid out in. +pub(crate) fn names_in_use(s: &Session) -> std::collections::HashSet { + let defined = s.docs.iter().flat_map(|d| d.doc.composite_fonts.iter().map(|f| f.name.to_lowercase())); + let held = FontDb::global().composite_font_names().into_iter().map(|n| n.to_lowercase()); + defined.chain(held).chain(s.composite_names_registered.iter().cloned()).collect() +} + /// Leave out a file's unusable composite fonts before registering and measuring its type. pub(crate) fn filter_loaded(doc: &mut vectorcraft_doc::Document) { let db = FontDb::global(); @@ -172,6 +187,9 @@ fn replace(s: &mut Session, label: &str, after: Vec) -> Result<() } let changed: Vec = before.iter().filter(|b| !after.contains(b)).chain(after.iter().filter(|a| !before.contains(a))).map(|f| f.name.to_lowercase()).collect(); + // A refused or panicked edit leaves the document without the new fonts: `sync` forgets a name + // listed here that the document doesn't define. + s.composite_names.extend(after.iter().map(|f| f.name.to_lowercase())); let db = FontDb::global(); for f in &before { if !after.iter().any(|a| CompositeFont::same_name(&a.name, &f.name)) { diff --git a/crates/engine/src/cmd/compositefonts/tests.rs b/crates/engine/src/cmd/compositefonts/tests.rs index 670a85ee2..f51f066df 100644 --- a/crates/engine/src/cmd/compositefonts/tests.rs +++ b/crates/engine/src/cmd/compositefonts/tests.rs @@ -395,6 +395,150 @@ fn opened_document_measures_missing_composite_font_as_missing() { assert_eq!(actual.cached_bounds, Some(expected)); } +/// The native file of a document whose composite font `name` sets Roman at full size, with type +/// "Ab12" set in it → (the file, the type's id, its width). +fn full_roman_file(name: &str) -> (Vec, u64, f64) { + let mut s = session(); + let mut full = font(name); + full["parts"]["roman"]["size"] = json!(100); + s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": name})).unwrap()["id"].as_u64().unwrap(); + let width = text(&s, id).cached_bounds.unwrap().width(); + (vectorcraft_format::save_file(&s.doc().unwrap().doc), id, width) +} + +/// Type in a missing font that no composite font is named after keeps the bounds it was measured +/// with when its file was read: opening the document does not lay it out again. +#[test] +fn opening_leaves_type_in_an_unrelated_missing_font_as_measured() { + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Unrelated")]})).unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "No Such Family CF"})).unwrap()["id"].as_u64().unwrap(); + let mut doc = (*s.doc().unwrap().doc).clone(); + let kept = vectorcraft_geom::Rect::new(1.0, 2.0, 3.0, 4.0); + if let NodeKind::Text(t) = &mut doc.node_mut(NodeId(id)).unwrap().kind { + t.cached_bounds = Some(kept); + } + s.add_document(doc, None); + assert_eq!(text(&s, id).cached_bounds, Some(kept)); +} + +/// Text holding an inline graphic whose symbol's art is type in a composite font is measured with +/// the graphic at the size the opened document's own definition gives it. +#[test] +fn opening_measures_inline_graphics_of_composite_type_in_its_own_definition() { + let mut s = session(); + let mut full = font("CF Inline"); + full["parts"]["roman"]["size"] = json!(100); + s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap(); + let art = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Inline"})).unwrap()["id"].as_u64().unwrap(); + s.execute("symbol.new", &json!({"name": "CF Inline Mark", "ids": [art]})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 150, "text": "x", "size": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.insertInline", &json!({"id": id, "at": 0, "symbol": "CF Inline Mark"})).unwrap(); + let width = text(&s, id).cached_bounds.unwrap().width(); + let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Inline")]})).unwrap(); + let file = crate::cmd::fileio::read_for_open("inline.vectorcraft", &bytes, None, &json!({}), false, false).unwrap(); + crate::cmd::fileio::open_read(&mut s, file).unwrap(); + assert!((text(&s, id).cached_bounds.unwrap().width() - width).abs() < 1e-6); +} + +/// Type read for opening while a composite font of its name was registered is measured again when +/// it opens, even after the document that defined the font has closed in between. +#[test] +fn opening_measures_type_read_in_a_composite_font_closed_since() { + let mut s = session(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Gone"})).unwrap()["id"].as_u64().unwrap(); + let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Gone")]})).unwrap(); + let file = crate::cmd::fileio::read_for_open("gone.vectorcraft", &bytes, None, &json!({}), false, false).unwrap(); + assert!(s.close_document(1)); + crate::cmd::fileio::open_read(&mut s, file).unwrap(); + let actual = text(&s, id); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// A file read for opening lays out its type in the composite fonts registered then (the front +/// document's, which defines the name with Roman at half size); once the file opens, its type is +/// measured again in its own. +#[test] +fn an_opened_file_measures_its_type_in_its_own_composite_fonts() { + let (bytes, id, width) = full_roman_file("CF Opened"); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Opened")]})).unwrap(); + let file = crate::cmd::fileio::read_for_open("opened.vectorcraft", &bytes, None, &json!({}), false, false).unwrap(); + crate::cmd::fileio::open_read(&mut s, file).unwrap(); + let actual = text(&s, id); + assert!((actual.cached_bounds.unwrap().width() - width).abs() < 1e-6); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// A document replaced while in the back (File › Revert) is measured in its own composite fonts +/// when it comes to the front, not in the front document's. +#[test] +fn a_document_replaced_in_the_back_is_measured_in_its_own_composite_fonts() { + let (bytes, id, width) = full_roman_file("CF Replaced"); + let mut s = session(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Replaced")]})).unwrap(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Replaced")]})).unwrap(); + assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap())); + assert!(s.set_active(0)); + let actual = text(&s, id); + assert!((actual.cached_bounds.unwrap().width() - width).abs() < 1e-6); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// So is one that comes to the front because the front document closes. +#[test] +fn a_document_replaced_in_the_back_is_measured_when_the_front_one_closes() { + let (bytes, id, width) = full_roman_file("CF Closed"); + let mut s = session(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Closed")]})).unwrap(); + assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap())); + assert!(s.close_document(1)); + let actual = text(&s, id); + assert!((actual.cached_bounds.unwrap().width() - width).abs() < 1e-6); + let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds; + assert_eq!(actual.cached_bounds, Some(expected)); +} + +/// A failed batch takes back the composite fonts it set, in the font database too. +#[test] +fn a_failed_batch_takes_back_its_composite_fonts() { + let db = vectorcraft_text::FontDb::global(); + let mut s = session(); + let steps = json!([ + {"command": "type.compositeFonts.set", "params": {"fonts": [font("CF Failed Batch")]}}, + {"command": "no.such.command"}, + ]); + assert!(s.execute("command.batch", &json!({"commands": steps})).is_err()); + assert!(names(&s).is_empty()); + assert!(db.composite_font("CF Failed Batch").is_none()); +} + +/// A composite font whose edit is refused, because it makes selected type too wide for the canvas, +/// is not left in the font database. +#[test] +fn a_refused_edit_takes_back_its_new_composite_font() { + let db = vectorcraft_text::FontDb::global(); + let mut s = session(); + let x = crate::MAX_COORD - 1000.0; + s.execute("text.create", &json!({"x": x, "y": 50, "text": "MMMMMMMM", "size": 100, "font": "CF Refused Edit"})).unwrap(); + let wide = json!({"name": "CF Refused Edit", "parts": {"kanji": {"font": "Source Sans 3", "size": 400}}}); + let e = s.execute("type.compositeFonts.set", &json!({"fonts": [wide]})).unwrap_err().to_string(); + assert!(e.contains("exceed the canvas"), "{e}"); + assert!(names(&s).is_empty()); + assert!(db.composite_font("CF Refused Edit").is_none()); +} + /// A loaded composite font cannot replace an installed family used by existing type. #[test] fn loaded_composite_font_cannot_take_an_installed_family_name() { diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index c7bee5400..297279b64 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -78,7 +78,7 @@ pub fn specs() -> Vec { "3D Lighting Preset", [], None, - "{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index; one undo step → {ids}", + "{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), which has no lighting presets; one undo step → {ids}", has_doc, lighting_preset ), @@ -505,6 +505,13 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result { if !effect.is_some_and(|e| effects::is_three_d(&e.id)) { return Err(bad(C, format!("object {} needs a 3D effect at index {index}", id.0))); } + // Extrude & Bevel (Classic) has no lighting presets; its lights are set in its own options. + if effect.is_some_and(|e| e.id == effects::CLASSIC_EXTRUDE) { + return Err(bad( + C, + format!("object {}: Extrude & Bevel (Classic) at index {index} has no lighting presets; set its lights with effect.setParams", id.0), + )); + } } let mut options = p.clone(); let map = options.as_object_mut().ok_or_else(|| bad(C, "parameters must be an object"))?; @@ -516,7 +523,7 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result { /// `effect.setParams`: merges `params` into the effect at `index` of the targets' active item, and /// sets its Warp style (`warpStyle`), its 3D type (`threeDType`) or visibility (`visible`), as one /// undo step. They are checked as `effect.apply` checks them, and a visible 3D effect must still -/// fit. → `{ids}`. +/// fit; a hidden one has its given values checked. → `{ids}`. fn set_params(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.setParams"; let index = index_param(p, "index", C)?; @@ -574,7 +581,6 @@ fn set_params(s: &mut Session, p: &Value) -> Result { } if let Some(e) = effect && effects::is_three_d(&e.id) - && visible != Some(false) { let to = kind.unwrap_or(&e.id); let mut prospective = @@ -582,7 +588,14 @@ fn set_params(s: &mut Session, p: &Value) -> Result { if let (Some(cur), Value::Object(new)) = (prospective.as_object_mut(), effects::upgrade_params(to, ¶ms)) { cur.extend(new); } - effects::validate_three_d(n, to, &prospective).map_err(|e| bad(C, e))?; + if visible != Some(false) { + effects::validate_three_d(n, to, &prospective).map_err(|e| bad(C, e))?; + } else { + // Hidden, the effect draws nothing, so its object need not fit. The values given are + // checked, and after a change of 3D type so is every value it keeps. + let checked = if to == e.id { effects::upgrade_params(to, ¶ms) } else { prospective }; + effects::validate_three_d_params(to, &checked).map_err(|e| bad(C, e))?; + } } } let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), index, |fx, index| { diff --git a/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs b/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs index 82426c5ea..812ce0966 100644 --- a/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs +++ b/crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs @@ -91,3 +91,27 @@ fn lighting_presets_reject_a_mixed_selection_before_changing_any_effect() { assert_eq!(s.doc().unwrap().history.undo.len(), history); assert!(!s.doc().unwrap().is_dirty()); } + +#[test] +fn lighting_presets_refuse_extrude_and_bevel_classic_and_leave_a_mixed_selection_unchanged() { + let (mut s, first) = setup("threeD.rotate", Value::Null); + let classic = s.execute("shape.rectangle", &json!({"x":250,"y":100,"width":80,"height":80})).unwrap()["id"].as_u64().unwrap(); + s.execute("effect.apply", &json!({"effect":"threeD.classicExtrude","item":null,"params":{"surface":"diffuse","lightRotation":40}})).unwrap(); + for ids in [vec![classic], vec![first.0, classic]] { + for preset in LIGHTING_PRESETS { + s.doc_mut().unwrap().mark_saved(); + let before = s.doc().unwrap().doc.clone(); + let history = s.doc().unwrap().history.undo.len(); + let p = json!({"ids":ids,"index":0,"item":null,"preset":preset.id}); + let err = s.execute("effect.lightingPreset", &p).unwrap_err().to_string(); + assert!(err.contains(&format!("object {classic}: Extrude & Bevel (Classic) at index 0 has no lighting presets")), "{err}"); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert!(!s.doc().unwrap().is_dirty()); + } + } + // Its lights are set with its own parameters instead. + s.execute("effect.setParams", &json!({"ids":[classic],"index":0,"item":null,"params":{"lightRotation":135,"lightHeight":45}})).unwrap(); + let params = &s.doc().unwrap().doc.node(NodeId(classic)).unwrap().appearance.effects[0].params; + assert_eq!((params["lightRotation"].as_f64(), params["lightHeight"].as_f64()), (Some(135.0), Some(45.0))); +} diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index be1dd443c..e3fca957b 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -46,6 +46,56 @@ fn revolve_commands_keep_profile_live_edit_options_expand_and_undo() { assert_eq!(effects_for_test(&s, id), before); } +/// `effect.setParams` checks the values it is given for a 3D effect when the same edit hides it: +/// a bad value is refused and the document is left as it was, and good values are stored. +#[test] +fn hiding_a_3d_effect_still_checks_its_new_values() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let rect = |s: &mut Session, x: u32| { + NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 60, "height": 60})).unwrap()["id"].as_u64().unwrap()) + }; + let classic = rect(&mut s, 20); + s.execute("effect.apply", &json!({"effect": effects::CLASSIC_EXTRUDE, "ids": [classic.0], "item": null})).unwrap(); + let extrude = rect(&mut s, 120); + s.execute("effect.apply", &json!({"effect": effects::EXTRUDE, "ids": [extrude.0], "item": null})).unwrap(); + let profile = NodeId(s.execute("path.create", &json!({"d": "M 300 40 L 330 60 L 330 140 L 300 160"})).unwrap()["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect": effects::REVOLVE, "ids": [profile.0], "item": null})).unwrap(); + for (id, params, why) in [ + (classic, json!({"depth": 2001}), "`depth` must be between 0 and 2000"), + (classic, json!({"surface": "glossy"}), "`surface` must be one of"), + (classic, json!({"bevel": "wavy"}), "bevel must be none or one of"), + (extrude, json!({"lights": []}), "lights must contain one to ten lights"), + (extrude, json!({"lightColor": "red"}), "lightColor must be an opaque RGB color"), + (profile, json!({"edge": "top"}), "Revolve edge must be left or right"), + ] { + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let p = json!({"ids": [id.0], "index": 0, "item": null, "params": params, "visible": false}); + let e = s.execute("effect.setParams", &p).unwrap_err().to_string(); + assert!(e.contains(why), "{params}: {e}"); + assert_eq!(s.doc().unwrap().doc, before, "{params}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{params}"); + } + // A hidden switch of 3D type checks the values given with it for the new type. + let before = s.doc().unwrap().doc.clone(); + let p = json!({"ids": [extrude.0], "index": 0, "item": null, "threeDType": "inflate", "params": {"lights": []}, "visible": false}); + let e = s.execute("effect.setParams", &p).unwrap_err().to_string(); + assert!(e.contains("lights must contain one to ten lights"), "{e}"); + assert_eq!(s.doc().unwrap().doc, before); + // Hiding alone checks nothing. + s.execute("effect.setParams", &json!({"ids": [extrude.0], "index": 0, "item": null, "visible": false})).unwrap(); + assert!(!node(&s, extrude).appearance.effects[0].visible); + s.execute("effect.setParams", &json!({"ids": [classic.0], "index": 0, "item": null, "params": {"depth": 2000}, "visible": false})).unwrap(); + let e = &node(&s, classic).appearance.effects[0]; + assert_eq!((e.params["depth"].as_f64(), e.visible), (Some(2000.0), false)); + // A value the effect already holds is not checked again: a key it does not take, as a file + // can carry, does not stop a hidden edit of another value. + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(classic).unwrap().appearance.effects[0].params["extra"] = json!(1); + s.execute("effect.setParams", &json!({"ids": [classic.0], "index": 0, "item": null, "params": {"depth": 10}, "visible": false})).unwrap(); + assert_eq!(node(&s, classic).appearance.effects[0].params["depth"].as_f64(), Some(10.0)); +} + /// Extrude & Bevel, Inflate and Rotate: applied by command, saved and reopened, edited, /// exported, expanded and undone; one 3D effect per object. #[test] diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index db5e6b117..6bc726e23 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -551,8 +551,8 @@ fn takes_over(s: &Session, plugin: &Plugin) -> bool { } /// `plugin.install`: loads and checks the module, refuses one that asks for a heavy budget unless -/// `allowHeavy` says the user agreed, one that lists permissions the user hasn't granted its id -/// unless `grant` lists them all, one that would take another module's place ([`takes_over`]) +/// `allowHeavy` says the user agreed, one that lists permissions the user hasn't granted this very +/// module unless `grant` lists them all, one that would take another module's place ([`takes_over`]) /// unless `replace` is true, and one whose SHA-256 isn't the `hash` given; records the grant with /// the module's hash, keeps it for the next start and installs it → its `plugin.list` entry. fn install(s: &mut Session, p: &Value) -> Result { @@ -597,8 +597,7 @@ fn install(s: &mut Session, p: &Value) -> Result { )); } // A plug-in that lists permissions installs once the user grants them: whoever installs it - // asks them first, unless they already granted all of them (with a plug-in store, to this - // module). + // asks them first, unless they already granted this very module all of them. let m = plugin.manifest(); let wanted = m.permissions(); if !s.plugin_approved(&plugin) && !grant.as_ref().is_some_and(|g| wanted.iter().all(|w| g.contains(w))) { @@ -606,13 +605,22 @@ fn install(s: &mut Session, p: &Value) -> Result { let asked = permissions::names(&wanted).join(", "); return Err(bad(C, format!("“{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees"))); } + // Installed plug-ins stay installed after a restart (#2541). The module is written first and its + // grant only once it is there; when the grant can't be written, the store gets the module it + // held back. A failed write of that module is only logged, and the grant then doesn't cover + // the new module. A stored module that can't be read back isn't replaced by one that needs a + // grant. + let previous = s.stored_plugin_bytes(plugin.id()); + let mut stored = (previous.is_ok() || !m.asks_permissions()) && s.store_plugin(plugin.id(), &bytes); if m.asks_permissions() { let g = Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted }; registry::grant(g.clone()); - s.store_grant(&m.id, Some(g)); + if stored && !s.store_grant(&m.id, Some(g)) { + s.put_back_plugin(plugin.id(), previous.ok().flatten().as_deref()); + stored = false; + } } - // Installed plug-ins stay installed after a restart (#2541), with their settings and cache. - let stored = s.store_plugin(plugin.id(), &bytes); + // It gets the settings and cache kept for its id. s.attach_plugin_storage(plugin.id()); // Installing a disabled plug-in again (an updated version) turns it on for the next launch too, // also when it is disabled by its stored file alone (it stopped a launch while loading). When @@ -894,7 +902,7 @@ pub fn specs() -> Vec { "Install Plug-in", [], None, - "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash, and replacing the plug-in with one that lists no new permission needs none; with a plug-in store attached, only the grants kept there count, each for the module with its hash alone), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", + "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", always, install ), @@ -903,7 +911,7 @@ pub fn specs() -> Vec { "Inspect Plug-in", [], None, - "{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted its id: plugin.install then needs grant) and needsReplace: bool (true when it would take the place of another module with its id: plugin.install then needs replace: true)", + "{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted this very module, its bytes included: plugin.install then needs grant) and needsReplace: bool (true when it would take the place of another module with its id: plugin.install then needs replace: true)", always, inspect ), @@ -930,7 +938,7 @@ pub fn specs() -> Vec { "Reload Plug-ins", ["Object", "Plug-ins"], None, - "{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module, and one disabled with plugin.setEnabled, is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted its id; an error in Safe Mode) → {folder, loaded: [id], failed: [{file, error}]}", + "{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module, and one disabled with plugin.setEnabled, is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted this very module, its bytes included; an error in Safe Mode) → {folder, loaded: [id], failed: [{file, error}]}", always, reload ), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 27972ef20..03082e7f5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -175,6 +175,9 @@ pub struct Session { /// The composite fonts (lowercase names) that the open documents defined when the font /// database was last brought in step with them ([`cmd::compositefonts::sync`]). pub(crate) composite_names: Vec, + /// Every composite font (lowercase name) this session has registered in the font database: + /// type read for opening may have been laid out in one that is gone by the time it opens. + pub(crate) composite_names_registered: std::collections::HashSet, /// While a command that changes nothing in the document runs (a query, or one of /// [`command_sets::JOURNALED_READS`]): it reads the instance of an object selected in a symbol /// instance's art. diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 25e52e0f2..c3c112bd3 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -1,3 +1,7 @@ +use std::collections::HashSet; + +use vectorcraft_doc::{Node, TextObject}; + use super::{ Arc, Clipboard, Color, CommandInfo, DocState, Document, DrawMode, EngineError, GradientPaint, Journal, NodeKind, PaintDefaults, Prefs, Result, Session, ViewInfo, cmd, command_specs, file_access, @@ -84,6 +88,7 @@ impl Session { batch_steps: None, batch_paint_docs: Vec::new(), composite_names: Vec::new(), + composite_names_registered: HashSet::new(), reading: false, place_hand_off: None, queue_hand_off: None, @@ -182,10 +187,8 @@ impl Session { self.reset_tool_for_doc_switch(); } self.active = Some(index); - // Type is drawn in the composite fonts of the document now in front, and measured again - // when they differ from those of the document before. - if switched && cmd::compositefonts::sync(self) { - self.refresh_document_text_bounds(index); + if switched { + self.sync_front_composite_fonts(); } true } else { @@ -193,6 +196,16 @@ impl Session { } } + /// Register the composite fonts of the document now in front ([`cmd::compositefonts::sync`]), + /// after a switch or a close brought it there. Its type set in a composite font that differs + /// from the one of that name before (added, changed or removed) is measured again, and type not + /// measured yet (in a document replaced while in the back) is measured now. + fn sync_front_composite_fonts(&mut self) { + let changed: HashSet = cmd::compositefonts::sync(self).into_iter().collect(); + let Some(index) = self.active else { return }; + self.refresh_document_text_bounds(index, |t| t.cached_bounds.is_none() || sets_in_any(t, &changed)); + } + /// The name [`Session::next_untitled`] gives next (New Document's Name field). pub fn peek_untitled(&self) -> String { format!("Untitled-{}", self.untitled_counter + 1) @@ -211,19 +224,44 @@ impl Session { pub(crate) fn refresh_text_bounds(doc: &mut Document) { // Inline graphics in text take their size from their symbols' art (not saved either). doc.resolve_inline_art(); - let trees = doc.layers.iter_mut().chain(doc.symbols.iter_mut().map(|s| &mut s.art)); - let trees = trees.chain(doc.graph_designs.iter_mut().map(|g| &mut g.art)); - for tree in trees.chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) { - refresh_tree_text_bounds(tree); + let mut uncached = |t: &TextObject| t.cached_bounds.is_none(); + for tree in text_trees_mut(doc) { + each_text_where(tree, &mut uncached, &mut cmd::typecmd::refresh_bounds_for_first_frame); } } - /// [`Session::refresh_text_bounds`] for open document `index`, as a change that is not an - /// edit: a saved document stays saved, and its undo history keeps its documents as they are. - fn refresh_document_text_bounds(&mut self, index: usize) { - if let Some(st) = self.docs.get_mut(index) { - st.change_unsaved(|doc| Self::refresh_text_bounds(Arc::make_mut(doc))); + /// Measure the type in open document `index` that `stale` picks (in its trees as + /// [`Session::refresh_text_bounds`] walks them), as a change that is not an edit: a saved + /// document stays saved, and its undo history keeps its documents as they are. The document is + /// left as it is when `stale` picks nothing. + fn refresh_document_text_bounds(&mut self, index: usize, mut stale: impl FnMut(&TextObject) -> bool) { + let Some(st) = self.docs.get_mut(index) else { return }; + if !text_trees(&st.doc).any(|n| has_text_where(n, &mut stale)) { + return; } + st.change_unsaved(|doc| { + let doc = Arc::make_mut(doc); + for tree in text_trees_mut(doc) { + each_text_where(tree, &mut stale, &mut cmd::typecmd::refresh_bounds_for_first_frame); + } + // Inline graphics take their size from their symbols' art, whose type may just have + // been measured again: the text holding one whose size changed is measured again too. + if !doc.resolve_inline_art().is_empty() { + for tree in text_trees_mut(doc) { + each_text_where(tree, &mut |t| t.cached_bounds.is_none(), &mut cmd::typecmd::refresh_bounds_for_first_frame); + } + } + }); + } + + /// Register the composite fonts of open document `index`, which is in front, and measure its + /// type not measured yet and its type set in a font of a name that a composite font of an open + /// document or of the font database has, or had before: that type may have been laid out in + /// another document's composite fonts (those registered while its file was read for opening). + fn sync_opened_composite_fonts(&mut self, index: usize) { + let mut names = cmd::compositefonts::names_in_use(self); + names.extend(cmd::compositefonts::sync(self)); + self.refresh_document_text_bounds(index, |t| t.cached_bounds.is_none() || sets_in_any(t, &names)); } /// Add a document and make it active. Between `edit.undoGroup.begin` and `edit.undoGroup.end`, @@ -234,6 +272,8 @@ impl Session { // (importers and the native reader have seen to theirs already, with a warning). doc.limit_depth(); cmd::compositefonts::filter_loaded(&mut doc); + // Inline graphics in text take their size from their symbols' art (not saved). + doc.resolve_inline_art(); // Guides from files saved before guides had layers go on a layer. doc.adopt_guides(); cmd::dimension::conform(&mut doc); @@ -244,8 +284,7 @@ impl Session { self.docs.push(st); let i = self.docs.len() - 1; self.active = Some(i); - cmd::compositefonts::sync(self); - self.refresh_document_text_bounds(i); + self.sync_opened_composite_fonts(i); if self.depth == 0 && let Some(label) = self.explicit_undo_group.clone() { @@ -262,11 +301,26 @@ impl Session { } doc.limit_depth(); cmd::compositefonts::filter_loaded(&mut doc); + doc.resolve_inline_art(); doc.adopt_guides(); cmd::dimension::conform(&mut doc); + // In the back, type set in a font named like a composite font is measured when the document + // comes to the front and its own composite fonts are registered, not in the front + // document's. + let mut back_names = None; if self.active == Some(index) { // Pending tool work (typing, a drag) belongs to the content being thrown away. self.reset_tool_for_doc_switch(); + } else { + let mut names = cmd::compositefonts::names_in_use(self); + names.extend(doc.composite_fonts.iter().map(|f| f.name.to_lowercase())); + for tree in text_trees_mut(&mut doc) { + each_text_where(tree, &mut |t| t.cached_bounds.is_some() && sets_in_any(t, &names), &mut |t| { + t.cached_bounds = None; + t.cached_baselines.clear(); + }); + } + back_names = Some(names); } let old = &self.docs[index]; let mut st = DocState::new(doc, old.path.clone()); @@ -297,12 +351,10 @@ impl Session { if let Some(stash) = &mut self.batch_stash { stash.push(old); } - // Type in the composite fonts of a document in the back is measured again when it comes to - // the front. - if self.active == Some(index) { - cmd::compositefonts::sync(self); + match back_names { + Some(names) => self.refresh_document_text_bounds(index, |t| t.cached_bounds.is_none() && !sets_in_any(t, &names)), + None => self.sync_opened_composite_fonts(index), } - self.refresh_document_text_bounds(index); true } @@ -319,7 +371,7 @@ impl Session { stash.push(old); } self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) }; - cmd::compositefonts::sync(self); + self.sync_front_composite_fonts(); true } @@ -351,27 +403,49 @@ impl Session { } } -/// Lay out the type in tree `n` (opacity mask art included) whose bounds aren't cached. Only the -/// nodes on the way to such type are copied. -fn refresh_tree_text_bounds(n: &mut Arc) { - fn stale(n: &vectorcraft_doc::Node) -> bool { - matches!(&n.kind, NodeKind::Text(t) if t.cached_bounds.is_none()) - || n.extras().mask.as_deref().is_some_and(|m| stale(&m.art)) - || n.children().is_some_and(|ch| ch.iter().any(|c| stale(c))) - } - if !stale(n) { +/// Whether some of type `t` is set in a font named (in any case) one of `names`, lowercased. +fn sets_in_any(t: &TextObject, names: &HashSet) -> bool { + !names.is_empty() && t.runs.iter().any(|r| names.contains(&r.style.font_family.to_lowercase())) +} + +/// The trees of `doc` that hold type: its layers, symbol definitions, graph designs and pattern +/// tiles. +fn text_trees(doc: &Document) -> impl Iterator> { + let trees = doc.layers.iter().chain(doc.symbols.iter().map(|s| &s.art)); + let trees = trees.chain(doc.graph_designs.iter().map(|g| &g.art)); + trees.chain(doc.patterns.iter().flat_map(|p| p.art.iter())) +} + +/// [`text_trees`], to change. +fn text_trees_mut(doc: &mut Document) -> impl Iterator> { + let trees = doc.layers.iter_mut().chain(doc.symbols.iter_mut().map(|s| &mut s.art)); + let trees = trees.chain(doc.graph_designs.iter_mut().map(|g| &mut g.art)); + trees.chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) +} + +/// Whether tree `n` (opacity mask art included) holds type that `pick` picks. +fn has_text_where(n: &Node, pick: &mut dyn FnMut(&TextObject) -> bool) -> bool { + matches!(&n.kind, NodeKind::Text(t) if pick(t)) + || n.extras().mask.as_deref().is_some_and(|m| has_text_where(&m.art, pick)) + || n.children().is_some_and(|ch| ch.iter().any(|c| has_text_where(c, pick))) +} + +/// Run `act` on the type in tree `n` (opacity mask art included) that `pick` picks. Only the nodes +/// on the way to such type are copied. +fn each_text_where(n: &mut Arc, pick: &mut dyn FnMut(&TextObject) -> bool, act: &mut dyn FnMut(&mut TextObject)) { + if !has_text_where(n, pick) { return; } let n = Arc::make_mut(n); if let NodeKind::Text(t) = &mut n.kind - && t.cached_bounds.is_none() + && pick(t) { - cmd::typecmd::refresh_bounds_for_first_frame(t); + act(t); } if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) { - refresh_tree_text_bounds(&mut m.art); + each_text_where(&mut m.art, pick, act); } for c in n.children_mut().into_iter().flatten() { - refresh_tree_text_bounds(c); + each_text_where(c, pick, act); } } diff --git a/crates/engine/src/session/execute.rs b/crates/engine/src/session/execute.rs index a8cc8ff5a..5f245ce90 100644 --- a/crates/engine/src/session/execute.rs +++ b/crates/engine/src/session/execute.rs @@ -135,6 +135,9 @@ impl Session { let r = if self.depth == 0 { self.run_guarded(id, run) } else { self.run_nested(run) }; if r.is_err() && self.depth == 0 { self.batch_paint_docs.clear(); + // A failed or panicked command's documents are put back: type is drawn in their + // composite fonts again, not in those the command set. + cmd::compositefonts::sync(self); } let r = r?; if self.depth == 0 { diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 27b9ad8ac..f7fba0a9b 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -289,8 +289,8 @@ impl Session { /// Install the plug-ins kept in the plug-in folder: none in Safe Mode, and none the user /// disabled (matched by file name, which is made from the id). The launch marker names each /// file while it loads. A module that doesn't load, and one that lists permissions the grants - /// there don't grant it, is skipped with a log record. A grant covers only the module it was - /// given to ([`grant_covers`]). + /// there don't grant that very module ([`grant_covers`]), is skipped with a log record, and + /// stays in the folder with its grant. pub fn load_stored_plugins(&mut self) { let Some(dir) = self.plugin_dir.clone() else { return }; if safe_mode::active() { @@ -376,9 +376,9 @@ impl Session { written && merged.complete() } - /// Whether `plugin` installs without asking the user: it lists no permissions, or its id was - /// granted every one it lists, in the grants of the attached plug-in store when there is one - /// (for this module alone, [`grant_covers`]), else in this process's + /// Whether `plugin` installs without asking the user: it lists no permissions, or this very + /// module (its id and SHA-256) was granted every one it lists, in the grants of the attached + /// plug-in store when there is one ([`grant_covers`]), else in this process's /// ([`vectorcraft_plugins::registry::approved`]). pub(crate) fn plugin_approved(&self, plugin: &vectorcraft_plugins::Plugin) -> bool { self.plugin_approver()(plugin) @@ -403,6 +403,29 @@ impl Session { self.plugin_dir.as_deref().is_some_and(|dir| put(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)), bytes)) } + /// The module the store holds for the plug-in `id`: `None` without a folder or such a file, and + /// an error, with a log record, when the file can't be read or is larger than the module limit. + pub(crate) fn stored_plugin_bytes(&self, id: &str) -> Result>, String> { + let Some(dir) = self.plugin_dir.as_deref() else { return Ok(None) }; + let path = dir.join(format!("{}.{PLUGIN_EXT}", file_stem(id))); + if !path.exists() { + return Ok(None); + } + let max = vectorcraft_plugins::Limits::default().max_module_bytes as u64; + read_capped(&path, max).map(Some).inspect_err(|e| log::warn!("can't read the stored plug-in {}: {e}", path.display())) + } + + /// Return the store's module for the plug-in `id` to `previous`, the bytes it held before a + /// write ([`Session::stored_plugin_bytes`]): with `None`, the file is removed. + pub(crate) fn put_back_plugin(&self, id: &str, previous: Option<&[u8]>) { + match previous { + Some(bytes) => { + self.store_plugin(id, bytes); + } + None => self.unstore_plugin(id), + } + } + /// The file the plug-in `id` is stored in, spelled as [`Session::load_stored_plugins`] names it /// (`None` without a plug-in folder). pub(crate) fn stored_plugin_file(&self, id: &str) -> Option { @@ -413,14 +436,15 @@ impl Session { /// other grants as they are; with none left, remove the file. Entries this version can't use /// are written back as they were, except one for `id`. A grants file that is too large, can't /// be read or doesn't hold a list is left as it is, with a log record, and nothing is recorded; - /// so is one whose new content would be larger than `MAX_GRANTS_FILE`. - pub(crate) fn store_grant(&self, id: &str, grant: Option) { - let Some(dir) = &self.plugin_dir else { return }; + /// so is one whose new content would be larger than `MAX_GRANTS_FILE`. → whether the store now + /// holds what was asked (`false` without a folder). + pub(crate) fn store_grant(&self, id: &str, grant: Option) -> bool { + let Some(dir) = &self.plugin_dir else { return false }; let mut file = match read_grants(dir) { Ok(file) => file, Err(e) => { log::warn!("not updating the stored plug-in grants {}: {e}", dir.join(GRANTS_FILE).display()); - return; + return false; } }; let others = file.other.len(); @@ -430,22 +454,23 @@ impl Session { None => file.grants.remove(id).is_some(), } || file.other.len() != others; if !changed { - return; + return true; } let mut entries: Vec = file.grants.into_values().filter_map(|g| serde_json::to_value(g).ok()).collect(); entries.extend(file.other); if entries.is_empty() { take(dir, GRANTS_FILE); - return; + return true; } // The file is read back only up to its cap: written compactly when pretty-printed is larger, // and not at all when that is too. let fits = |bytes: &Vec| bytes.len() as u64 <= MAX_GRANTS_FILE; match serde_json::to_vec_pretty(&entries).ok().filter(fits).or_else(|| serde_json::to_vec(&entries).ok().filter(fits)) { - Some(bytes) => { - put(dir, GRANTS_FILE, &bytes); + Some(bytes) => put(dir, GRANTS_FILE, &bytes), + None => { + log::warn!("not updating the stored plug-in grants {}: {}", dir.join(GRANTS_FILE).display(), too_large(MAX_GRANTS_FILE)); + false } - None => log::warn!("not updating the stored plug-in grants {}: {}", dir.join(GRANTS_FILE).display(), too_large(MAX_GRANTS_FILE)), } } @@ -577,12 +602,13 @@ fn io(e: &std::io::Error) -> StorageError { StorageError::Io(e.to_string()) } -/// Does grant `g` cover `plugin`: was it given to this module (the same SHA-256) for every +/// Does grant `g` cover `plugin`: was it given to this module (the same id and SHA-256) for every /// permission the module lists? A module with other bytes under the id needs the user's approval -/// again, and so doesn't get the settings and cache kept for the id without it, and so does every -/// module when the grant records no hash. +/// again, and so doesn't get the settings and cache kept for the id without it. A grant whose hash +/// is missing or not a valid SHA-256 covers no module. fn grant_covers(g: &vectorcraft_plugins::permissions::Grant, plugin: &vectorcraft_plugins::Plugin) -> bool { - !g.hash.is_empty() && g.hash == plugin.hash() && g.covers(&plugin.manifest().permissions()) + let m = plugin.manifest(); + g.covers(&m.id, plugin.hash(), &m.permissions()) } /// Is `path` a link (a symbolic link or, on Windows, a junction)? diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index 644120d34..84fa42dc5 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -164,6 +164,33 @@ fn a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_ } } +/// A gradient fitted to the object turns with a Transform effect on the canvas, and Object › Expand +/// of the fill makes strips that draw the same. +#[test] +fn a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it() { + // The gray level at (x, y). + let gray = |r: &vectorcraft_render::Rendered, x: usize, y: usize| r.pixels[(y * 200 + x) * 4] as i32; + // The object's own effect, and the fill's (the first appearance item). + for (params, item) in + [(json!({"rotate": 90}), json!(null)), (json!({"rotate": 90}), json!(0)), (json!({"rotate": 30, "scaleH": 60}), json!(null))] + { + let mut s = session(); + let id = gradient_rect(&mut s, Rect::new(60.0, 60.0, 140.0, 140.0), white_black(), false); + run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": params, "ids": [id.0], "item": item})); + let before = render(&s); + if params["rotate"] == 90 { + // The white to black ramp runs up or down the turned square, not across it. + let (top, bottom) = (gray(&before, 100, 70), gray(&before, 100, 130)); + let (left, right) = (gray(&before, 70, 100), gray(&before, 130, 100)); + assert!((top - bottom).abs() > 100 && (left - right).abs() < 20, "{params} on item {item}: {top} {bottom} {left} {right}"); + } + run(&mut s, "object.expand", json!({"object": false, "stroke": false, "steps": 64})); + let after = render(&s); + let diff = mean_diff(&before, &after); + assert!(diff < 1.0, "{params} on item {item}: mean difference {diff}"); + } +} + #[test] fn a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it() { // The object's own effect, and the fill's (the first appearance item). diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 1e691acda..ec67887c5 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -579,9 +579,9 @@ fn asking(id: &str, permissions: &str, output: &Value) -> Vec { /// A plug-in whose manifest lists permissions installs only once they are all granted (`grant`), /// which `plugin.inspect` tells beforehand (`needsApproval`) with the permissions and the module's -/// hash. The grant is recorded with that hash: replacing the plug-in (`replace: true`) with one -/// that lists the same permissions needs no new grant, and one that lists another needs one. Removing it forgets the -/// grant. A plug-in without `permissions` installs as before, with the ones its kind implies. +/// hash. The grant is recorded with that hash and covers only that module: replacing the plug-in +/// (`replace: true`) with one with other bytes needs a new grant. Removing it forgets the grant. A +/// plug-in without `permissions` installs as before, with the ones its kind implies. #[test] fn a_plugin_that_lists_permissions_installs_once_they_are_granted() { let mut s = session(); @@ -617,11 +617,11 @@ fn a_plugin_that_lists_permissions_installs_once_they_are_granted() { assert_eq!(installed["permissions"], json!(["doc.read"])); let g = vectorcraft_plugins::registry::granted(id).unwrap(); assert_eq!((g.hash.as_str(), g.permissions.as_slice()), (hash.as_str(), &[vectorcraft_plugins::permissions::Permission::DocRead][..])); - // Replaced with another module that lists the same permissions: no new grant needed, and the - // grant now records the new module's hash. + // Replaced with another module that lists the same permissions: granted again, the grant now + // records the new module's hash. let other = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null})); - assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}))["needsApproval"], false); - run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other), "replace": true})); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}))["needsApproval"], true); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other), "grant": ["doc.read"], "replace": true})); assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, vectorcraft_plugins::permissions::sha256_hex(&other)); // One that asks for more is refused until it is granted all of them. let more = data(r#"["doc.read","doc.write"]"#); @@ -641,6 +641,221 @@ fn a_plugin_that_lists_permissions_installs_once_they_are_granted() { assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(DESATURATE)}))["needsApproval"], false); } +/// A module with other bytes under an id that was granted is not covered, even when it lists the +/// same permissions: `plugin.inspect` says so, `plugin.install` refuses it and leaves the installed +/// module and its grant alone, and a new `grant` installs it and records its hash. The same bytes +/// install again without asking, and a module that lists no permissions never asks. +#[test] +fn a_grant_covers_only_the_module_that_was_approved() { + use vectorcraft_plugins::permissions::sha256_hex; + let mut s = session(); + let id = "test.engine.permissions.bytes"; + let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null})); + let (first_data, changed_data) = (vectorcraft_format::base64_encode(&first), vectorcraft_format::base64_encode(&changed)); + let (first_hash, changed_hash) = (sha256_hex(&first), sha256_hex(&changed)); + run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]})); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": changed_data}))["needsApproval"], true); + let e = s.execute("plugin.install", &json!({"dataBase64": changed_data, "replace": true})).unwrap_err().to_string(); + assert!(e.contains("doc.read") && e.contains("`grant`"), "{e}"); + let e = s.execute("plugin.install", &json!({"dataBase64": changed_data, "replace": true, "hash": changed_hash})).unwrap_err().to_string(); + assert!(e.contains("`grant`"), "`hash` is not an approval: {e}"); + assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), first_hash, "the installed module stays"); + assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, first_hash, "its grant stays"); + // The same bytes need no new grant. + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": first_data}))["needsApproval"], false); + run(&mut s, "plugin.install", json!({"dataBase64": first_data})); + // A new grant installs the changed module, and the old bytes are no longer covered. + run(&mut s, "plugin.install", json!({"dataBase64": changed_data, "grant": ["doc.read"], "hash": changed_hash, "replace": true})); + assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), changed_hash); + assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, changed_hash); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": first_data}))["needsApproval"], true); + run(&mut s, "plugin.remove", json!({"id": id})); + + // A manifest without a list, or with an empty one, asks for nothing, whatever its bytes. + let exempt = "test.engine.permissions.exempt"; + for (permissions, out) in [("null", json!({"objects": []})), ("null", json!({"objects": [], "error": null})), ("[]", json!({"objects": []}))] { + let data = vectorcraft_format::base64_encode(&asking(exempt, permissions, &out)); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": data}))["needsApproval"], false, "{permissions}"); + run(&mut s, "plugin.install", json!({"dataBase64": data, "replace": true})); + } + run(&mut s, "plugin.remove", json!({"id": exempt})); +} + +/// A stored module whose bytes changed is skipped at the next start and stays in the store with its +/// grant, and the original bytes load again. A stored grant whose hash is malformed covers nothing. +/// With a store attached, a matching grant held only by the process doesn't count. +#[test] +fn a_stored_grant_covers_only_the_stored_module_it_recorded() { + use vectorcraft_plugins::permissions::{Grant, Permission, sha256_hex}; + let folder = crate::tests_links::Folder::new("plugin-store-recorded-module"); + let id = "test.engine.permissions.storedbytes"; + let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null})); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]})); + let grants = folder.0.join("grants.json"); + let recorded = std::fs::read(&grants).unwrap(); + let module = std::fs::read_dir(&folder.0) + .unwrap() + .map(|e| e.unwrap().path()) + .find(|p| p.extension().is_some_and(|x| x == "wasm")) + .expect("the stored module"); + let forget = || { + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + }; + let starts = || { + forget(); + let mut next = session(); + next.attach_plugin_store(folder.0.clone()); + vectorcraft_plugins::registry::get(id).is_some() + }; + assert!(starts(), "the module that was approved loads"); + std::fs::write(&module, &changed).unwrap(); + assert!(!starts(), "other bytes under the old grant are skipped"); + assert_eq!(std::fs::read(&module).unwrap(), changed, "the module stays in the store"); + assert_eq!(std::fs::read(&grants).unwrap(), recorded, "its grant stays"); + std::fs::write(&module, &first).unwrap(); + assert!(starts(), "the approved bytes load again"); + + // A malformed hash is no wildcard, even for the module on disk. + let upper = sha256_hex(&first).to_uppercase(); + for hash in ["", "00", upper.as_str()] { + std::fs::write(&grants, serde_json::to_vec(&json!([{"id": id, "hash": hash, "permissions": ["doc.read"]}])).unwrap()).unwrap(); + assert!(!starts(), "hash {hash:?}"); + } + + // The store's grants are the ones that count, even when the process holds one for these bytes. + std::fs::write(&grants, &recorded).unwrap(); + assert!(starts()); + let mut attached = session(); + attached.attach_plugin_store(folder.0.clone()); + vectorcraft_plugins::registry::grant(Grant { id: id.into(), hash: sha256_hex(&changed), permissions: vec![Permission::DocRead] }); + let inspected = run(&mut attached, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&changed)})); + assert_eq!(inspected["needsApproval"], true, "a grant held only by the process doesn't count with a store attached"); + run(&mut attached, "plugin.remove", json!({"id": id})); +} + +/// A write to the plug-in store that fails leaves the store's module beside the grant that approved +/// it, so the old module loads at the next start: a module that can't be written doesn't update the +/// grant, and a grant that can't be written puts the old module back. On a first install, it takes +/// the new module out again. +#[test] +fn a_failed_store_write_keeps_the_stored_module_with_its_grant() { + use vectorcraft_plugins::permissions::sha256_hex; + let folder = crate::tests_links::Folder::new("plugin-store-failed-write"); + let id = "test.engine.permissions.failedwrite"; + let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let changed = asking(id, r#"["doc.read","doc.write"]"#, &json!({"objects": []})); + let first_data = vectorcraft_format::base64_encode(&first); + let update = json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read", "doc.write"], "replace": true}); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]})); + let grants = folder.0.join("grants.json"); + let recorded = std::fs::read(&grants).unwrap(); + let module = std::path::PathBuf::from(s.stored_plugin_file(id).unwrap()); + let starts = || { + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + let mut next = session(); + next.attach_plugin_store(folder.0.clone()); + vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string()) + }; + let block = |path: &std::path::Path| { + std::fs::remove_file(path).unwrap(); + std::fs::create_dir_all(path.join("block")).unwrap(); + }; + + // The module can't be written: the grant stays the old module's. + block(&module); + run(&mut s, "plugin.install", update.clone()); + assert_eq!(std::fs::read(&grants).unwrap(), recorded, "the grant isn't updated for a module that wasn't stored"); + std::fs::remove_dir_all(&module).unwrap(); + std::fs::write(&module, &first).unwrap(); + assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads after a failed module write"); + + // The grants file is full: the new grant, which is longer, doesn't fit. + let cap = 1 << 20; + let mut entries: Vec = serde_json::from_slice(&recorded).unwrap(); + entries.push(json!({"id": "", "pad": ""})); + let room = cap - serde_json::to_vec(&entries).unwrap().len(); + *entries.last_mut().unwrap() = json!({"id": "", "pad": "x".repeat(room)}); + let full = serde_json::to_vec(&entries).unwrap(); + assert_eq!(full.len(), cap); + std::fs::write(&grants, &full).unwrap(); + run(&mut s, "plugin.install", update.clone()); + assert_eq!(std::fs::read(&grants).unwrap(), full, "not written past the cap"); + assert_eq!(std::fs::read(&module).unwrap(), first, "the old module is put back"); + assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads after a failed grant write"); + + // The grants file can't be read, so nothing can be recorded in it. + block(&grants); + run(&mut s, "plugin.install", update.clone()); + assert_eq!(std::fs::read(&module).unwrap(), first, "the old module is put back"); + std::fs::remove_dir_all(&grants).unwrap(); + std::fs::write(&grants, &recorded).unwrap(); + assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads once the grants are readable"); + + // A stored module that can't be read back isn't replaced, so a failed grant write can't lose it. + let oversized = vectorcraft_plugins::Limits::default().max_module_bytes as u64 + 1; + std::fs::File::options().write(true).open(&module).unwrap().set_len(oversized).unwrap(); + block(&grants); + run(&mut s, "plugin.install", update); + assert_eq!(std::fs::metadata(&module).unwrap().len(), oversized, "the stored module stays"); + std::fs::remove_dir_all(&grants).unwrap(); + std::fs::write(&grants, &recorded).unwrap(); + std::fs::write(&module, &first).unwrap(); + + // A first install whose grant can't be written leaves no module without a grant in the store. + run(&mut s, "plugin.remove", json!({"id": id})); + assert!(!module.exists() && !grants.exists()); + std::fs::create_dir_all(grants.join("block")).unwrap(); + run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]})); + assert!(!module.exists(), "the store holds no module its grants don't cover"); + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); +} + +/// A plug-in folder reports a changed module as needing approval and still loads the other files; +/// granted on its own, the changed module loads from the folder. A session without a store uses the +/// grants held by the process. +#[test] +fn a_plugin_folder_skips_a_changed_module_until_it_is_approved() { + let mut s = session(); + let id = "test.engine.permissions.folderbytes"; + let plain = "test.engine.permissions.folderplain"; + let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null})); + let dir = crate::tests_links::Folder::new("plugin-folder-changed-module"); + let file = dir.0.join("asks.wasm"); + std::fs::write(&file, &first).unwrap(); + std::fs::write(dir.0.join("plain.wasm"), asking(plain, "null", &json!({"objects": []}))).unwrap(); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]})); + let path = json!({"path": dir.0.display().to_string()}); + let r = run(&mut s, "plugin.reload", path.clone()); + assert_eq!((r["loaded"].as_array().unwrap().len(), r["failed"].as_array().unwrap().len()), (2, 0), "{r}"); + + std::fs::write(&file, &changed).unwrap(); + let r = run(&mut s, "plugin.reload", path.clone()); + assert_eq!(r["loaded"], json!([plain]), "the other file still loads"); + let failed = r["failed"].as_array().unwrap(); + assert_eq!(failed.len(), 1, "{r}"); + assert!(failed[0]["file"].as_str().unwrap().ends_with("asks.wasm"), "{r}"); + let why = failed[0]["error"].as_str().unwrap(); + assert!(why.contains("needs approval") && why.contains("doc.read"), "{why}"); + let hash = vectorcraft_plugins::permissions::sha256_hex(&first); + assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), hash, "the module installed earlier stays"); + + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read"], "replace": true})); + let r = run(&mut s, "plugin.reload", path); + assert_eq!(r["failed"], json!([]), "approved on its own, it loads: {r}"); + run(&mut s, "plugin.remove", json!({"id": id})); + run(&mut s, "plugin.remove", json!({"id": plain})); +} + /// The host enforces what a filter was granted: without `doc.write` an output that changes the /// document is refused and nothing changes, without `doc.read` it is given no objects, and without /// `state` it is given no data kept with the document and may not write any. @@ -876,18 +1091,25 @@ fn a_grant_covers_only_the_module_it_was_given_to() { std::fs::write(&stored[0], &other).unwrap(); vectorcraft_plugins::registry::remove(id); vectorcraft_plugins::registry::revoke(id); + vectorcraft_plugins::registry::set_storage(id, None); + let no_storage = |how: &str| { + assert!(vectorcraft_plugins::registry::storage(id).is_none(), "{how} gives another module the id's settings and cache"); + }; let mut next = session(); next.attach_plugin_store(folder.0.clone()); assert!(vectorcraft_plugins::registry::get(id).is_none(), "another module isn't loaded under the grant"); + no_storage("the next start"); let dir = crate::tests_links::Folder::new("plugin-folder-grant-hash"); std::fs::write(dir.0.join("other.wasm"), &other).unwrap(); let r = run(&mut next, "plugin.reload", json!({"path": dir.0.display().to_string()})); assert_eq!(r["loaded"], json!([])); let why = r["failed"][0]["error"].as_str().unwrap(); assert!(why.contains("needs approval"), "{why}"); + no_storage("a folder load"); let data = vectorcraft_format::base64_encode(&other); let e = next.execute("plugin.install", &json!({"dataBase64": data, "replace": true})).unwrap_err().to_string(); assert!(e.contains("`grant`"), "{e}"); + no_storage("a refused install"); // Granted, it reads the settings the first module set. run(&mut next, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true})); run(&mut next, "plugin.run", json!({"id": id, "ids": []})); diff --git a/crates/engine/tests/plugin_safe_mode.rs b/crates/engine/tests/plugin_safe_mode.rs index 6923df616..7fe54fb53 100644 --- a/crates/engine/tests/plugin_safe_mode.rs +++ b/crates/engine/tests/plugin_safe_mode.rs @@ -198,6 +198,25 @@ fn installing_a_disabled_plug_in_again_turns_it_on() { let _ = std::fs::remove_dir_all(&dir); } +/// When the grant of an update can't be stored, the store keeps the old module, and it stays disabled. +#[test] +fn a_disabled_plug_in_whose_new_grant_cant_be_stored_stays_disabled() { + let _f = fresh(); + let dir = tmp_dir("reinstall-no-grant"); + let mut s = launch(&dir); + let manifest = r#"{"id":"test.sm.g","name":"Test","version":"1","kind":"filter","permissions":["doc.read"]}"#; + let module = |out: &str| vectorcraft_format::base64_encode(&wat::parse_str(tpl::returning(manifest, out)).unwrap()); + s.execute("plugin.install", &json!({"dataBase64": module(r#"{"objects":[]}"#), "grant": ["doc.read"]})).unwrap(); + s.execute("plugin.setEnabled", &json!({"id": "test.sm.g", "enabled": false})).unwrap(); + let grants = dir.join("grants.json"); + std::fs::remove_file(&grants).unwrap(); + std::fs::create_dir_all(grants.join("block")).unwrap(); + s.execute("plugin.install", &json!({"dataBase64": module(r#"{"objects":[],"error":null}"#), "grant": ["doc.read"], "replace": true})).unwrap(); + assert_eq!(safe_mode::disabled().len(), 1, "the module the update could not replace stays disabled"); + safe_mode::set_disabled(vec![]); + let _ = std::fs::remove_dir_all(&dir); +} + /// A launch where the stored plug-in `id` stopped while loading: it is installed, then disabled by /// its stored file alone (as Disable and Continue saves it), and the next launch skips it. fn stored_plug_in_disabled_by_file(dir: &std::path::Path, id: &str) -> Session { diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 46daf84cc..9400bd56e 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -22,7 +22,8 @@ pub(super) enum Page { /// How art moved from a page to its place in the layers' document transforms: its pattern tiles /// move with it. -pub(super) const PAGE_MOVE: Scaling = Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: false, patterns: true }; +pub(super) const PAGE_MOVE: Scaling = + Scaling { strokes: false, effects: None, keep_type_strokes: false, keep_corners: false, patterns: true, keep_fit_when_flat: false }; /// The page (`visible`, the file read as its page) and the place of its area in the layers' /// document. diff --git a/crates/plugins/src/permissions.rs b/crates/plugins/src/permissions.rs index b2b7fad74..c276115c8 100644 --- a/crates/plugins/src/permissions.rs +++ b/crates/plugins/src/permissions.rs @@ -237,6 +237,11 @@ pub fn sha256_hex(bytes: &[u8]) -> String { Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect() } +/// Is `s` a SHA-256 as [`sha256_hex`] writes it: 64 lower-case hexadecimal digits? +fn is_sha256_hex(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) +} + /// What the user approved for a plug-in: its id, the SHA-256 of the module they approved /// ([`sha256_hex`]) and the permissions it was granted. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -247,9 +252,10 @@ pub struct Grant { } impl Grant { - /// Does it grant every one of `wanted`? - pub fn covers(&self, wanted: &[Permission]) -> bool { - wanted.iter().all(|w| self.permissions.contains(w)) + /// Does it grant every one of `wanted` to the module with this `id` and SHA-256 `hash`? A grant + /// whose own hash is not a valid SHA-256 ([`sha256_hex`]) covers nothing. + pub fn covers(&self, id: &str, hash: &str, wanted: &[Permission]) -> bool { + self.id == id && is_sha256_hex(&self.hash) && self.hash == hash && wanted.iter().all(|w| self.permissions.contains(w)) } } @@ -345,10 +351,37 @@ mod tests { #[test] fn a_grant_covers_its_permissions_and_no_more() { - let g = Grant { id: "a".into(), hash: String::new(), permissions: vec![Permission::DocRead, Permission::DocWrite] }; - assert!(g.covers(&[Permission::DocRead])); - assert!(g.covers(&[])); - assert!(!g.covers(&[Permission::State])); - assert!(!g.covers(&[Permission::DocRead, Permission::State])); + let hash = sha256_hex(b"module"); + let g = Grant { id: "a".into(), hash: hash.clone(), permissions: vec![Permission::DocRead, Permission::DocWrite] }; + assert!(g.covers("a", &hash, &[Permission::DocRead])); + assert!(g.covers("a", &hash, &[])); + assert!(!g.covers("a", &hash, &[Permission::State])); + assert!(!g.covers("a", &hash, &[Permission::DocRead, Permission::State])); + } + + #[test] + fn a_grant_covers_only_the_module_that_was_approved() { + let hash = sha256_hex(b"module"); + let wanted = [Permission::DocRead]; + let g = Grant { id: "a".into(), hash: hash.clone(), permissions: vec![Permission::DocRead] }; + assert!(g.covers("a", &hash, &wanted)); + assert!(!g.covers("b", &hash, &wanted), "another id"); + assert!(!g.covers("a", &sha256_hex(b"other"), &wanted), "other bytes"); + assert!(!g.covers("a", &sha256_hex(b"other"), &[]), "other bytes, even when nothing is wanted"); + } + + /// Damaged data can't stand for every module, even when the hash asked about is just as damaged. + #[test] + fn a_grant_with_an_invalid_hash_covers_nothing() { + let upper = sha256_hex(b"module").to_uppercase(); + let short = "ab".repeat(31); + let long = "ab".repeat(33); + let not_hex = "g".repeat(64); + let wide = "é".repeat(32); + for hash in ["", "00", upper.as_str(), short.as_str(), long.as_str(), not_hex.as_str(), wide.as_str()] { + let g = Grant { id: "a".into(), hash: hash.into(), permissions: vec![Permission::DocRead] }; + assert!(!g.covers("a", hash, &[Permission::DocRead]), "{hash:?}"); + assert!(!g.covers("a", hash, &[]), "{hash:?}"); + } } } diff --git a/crates/plugins/src/registry.rs b/crates/plugins/src/registry.rs index 62a7e1c67..824396508 100644 --- a/crates/plugins/src/registry.rs +++ b/crates/plugins/src/registry.rs @@ -56,11 +56,11 @@ pub fn revoke(id: &str) -> bool { } /// May `plugin` be installed without asking the user: does it list no permissions, or did the user -/// already grant its id every permission it lists? A module with other bytes but no new permission -/// needs no new approval. +/// already grant this very module (its id and SHA-256) every permission it lists? A module with +/// other bytes needs a new approval, even when it lists the same permissions. pub fn approved(plugin: &Plugin) -> bool { let m = plugin.manifest(); - !m.asks_permissions() || granted(&m.id).is_some_and(|g| g.covers(&m.permissions())) + !m.asks_permissions() || granted(&m.id).is_some_and(|g| g.covers(&m.id, plugin.hash(), &m.permissions())) } /// Installs `plugin`, replacing one with the same id. @@ -153,7 +153,7 @@ pub struct FolderReport { /// Installs every `*.wasm` directly in `dir` (not recursive), in file-name order. A bad module is /// reported and skipped, the others still load; so is a heavy one ([`crate::Budget::Heavy`]), /// which installs only once the user agrees, and one that lists permissions the user has not -/// granted its id ([`approved`]). +/// granted this very module ([`approved`]). #[cfg(not(target_arch = "wasm32"))] pub fn load_folder(dir: &std::path::Path) -> Result { load_folder_where(dir, |_| Ok(()), approved) diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index e6054b722..ef8972c3a 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -857,6 +857,41 @@ fn a_paths_gradient_moves_with_its_transform_and_free_distort() { assert!(red(rb(&img, 51, 50)) && blue(rb(&img, 78, 50)), "{:?} {:?}", rb(&img, 51, 50), rb(&img, 78, 50)); } +/// The canvas turns and reflects a gradient fitted to the square with its Transform effect, on the +/// object and as the fill's own effect. +#[test] +fn a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas() { + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let fitted = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let draw = |e: Effect, own: bool| { + let square = shapes::rectangle(vectorcraft_geom::Rect::new(10.0, 35.0, 40.0, 65.0)); + let mut n = Node::path(NodeId(0), square, Appearance::basic(fitted.clone(), Paint::None, 0.0)); + if own { + n.appearance.items[0].effects_mut().push(e); + } else { + n.appearance.effects.push(e); + } + render(&doc_with(Document::new(100.0, 100.0), n)) + }; + let rb = |img: &Rendered, x: u32, y: u32| { + let p = img.pixel(x, y); + (p[0], p[2]) + }; + let red = |(r, b): (u8, u8)| r > 200 && b < 60; + let blue = |(r, b): (u8, u8)| b > 200 && r < 60; + for own in [false, true] { + // A quarter turn: the left (red) to right (blue) ramp runs from the bottom up. + let img = draw(fx("distort.transform", json!({"rotate": 90})), own); + let (top, bottom) = (rb(&img, 25, 36), rb(&img, 25, 63)); + assert!(blue(top) && red(bottom), "own {own}: {top:?} {bottom:?}"); + assert_eq!(rb(&img, 12, 50), rb(&img, 38, 50), "own {own}: the same color across"); + // Reflected left to right: blue on the left, red on the right. + let img = draw(fx("distort.transform", json!({"reflectX": true})), own); + let (left, right) = (rb(&img, 11, 50), rb(&img, 38, 50)); + assert!(blue(left) && red(right), "own {own}: {left:?} {right:?}"); + } +} + #[test] fn a_paths_gradient_moves_and_scales_with_its_warped_bounds() { use vectorcraft_color::GradientGeom; diff --git a/crates/text/src/fontdb/composite.rs b/crates/text/src/fontdb/composite.rs index faee695ca..0e1d9afa8 100644 --- a/crates/text/src/fontdb/composite.rs +++ b/crates/text/src/fontdb/composite.rs @@ -31,6 +31,13 @@ impl FontDb { { let mut map = self.composites.write().unwrap_or_else(|e| e.into_inner()); if map.get(&key).is_some_and(|f| **f == *font) { + // The same definition a guard lends: it stays when the guard drops. + let mut loans = self.composite_loans.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(loan) = loans.get_mut(&key) + && map.get(&key).is_some_and(|have| Arc::ptr_eq(have, &loan.font)) + { + loan.adopted = true; + } return false; } map.insert(key, Arc::new(font.clone())); @@ -194,7 +201,7 @@ impl FontDb { } let font = Arc::new((*f).clone()); let previous = map.insert(key.clone(), font.clone()); - loans.insert(key.clone(), CompositeLoan { font: font.clone(), previous, guards: 1 }); + loans.insert(key.clone(), CompositeLoan { font: font.clone(), previous, guards: 1, adopted: false }); lent.push((key, font)); changed = true; } @@ -300,6 +307,9 @@ pub(super) struct CompositeLoan { previous: Option>, /// How many guards lend it. guards: usize, + /// Whether [`FontDb::set_composite_font`] set the same definition while it was lent: the last + /// guard to drop then leaves it in place. + adopted: bool, } /// The composite fonts [`FontDb::lend_composite_fonts`] made available, given back when it drops. @@ -327,9 +337,11 @@ impl Drop for LentCompositeFonts<'_> { continue; } let previous = loan.previous.take(); + let adopted = loan.adopted; loans.remove(key); - // Given back unless a font of that name has been set or removed since. - if map.get(key).is_some_and(|have| Arc::ptr_eq(have, font)) { + // Given back unless a font of that name has been set (the same definition included) + // or removed since. + if !adopted && map.get(key).is_some_and(|have| Arc::ptr_eq(have, font)) { match previous { Some(p) => map.insert(key.clone(), p), None => map.remove(key), diff --git a/crates/text/src/tests/composite.rs b/crates/text/src/tests/composite.rs index 370fa4557..729afa0d5 100644 --- a/crates/text/src/tests/composite.rs +++ b/crates/text/src/tests/composite.rs @@ -129,6 +129,13 @@ fn lent_composite_fonts_last_as_long_as_a_guard() { db.set_composite_font(&replaced); drop(lent); assert_eq!(db.composite_font("Lent").unwrap().parts.roman.size, 60.0); + // So does the same definition set while lent, after every guard lending it drops. + assert!(db.remove_composite_font("Lent")); + let (one, two) = (db.lend_composite_fonts(&fonts[..1]), db.lend_composite_fonts(&fonts[..1])); + assert!(!db.set_composite_font(&fonts[0])); + drop(one); + drop(two); + assert!(db.composite_font("Lent").is_some()); // A guard whose font was removed doesn't give back a later guard's loan of that name. assert!(db.remove_composite_font("Lent")); let old = db.lend_composite_fonts(&fonts[..1]); diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index ff21f37ba..6ce5f3501 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -11,7 +11,7 @@ mod visibility; pub use solid::{Bevel, BevelShape, Extrude, Facet, Inflate, View, extrude, inflate, inflate_together, plane}; pub use visibility::{VisibleFace, visible_faces}; mod shading; -pub use shading::{LightGradient, RgbGradient, ShadeGradient}; +pub use shading::{ClassicShading, ClassicSurface, LightGradient, RgbGradient, ShadeGradient}; mod lights; pub use lights::{Light, Lights, MAX_LIGHTS}; mod caps; diff --git a/crates/three-d/src/shading.rs b/crates/three-d/src/shading.rs index b0772e7cf..d351e9749 100644 --- a/crates/three-d/src/shading.rs +++ b/crates/three-d/src/shading.rs @@ -1,7 +1,99 @@ -//! Gouraud lighting represented by an ordinary vector linear gradient on each triangle. +//! Gouraud lighting represented by an ordinary vector linear gradient on each triangle, and the +//! flat surface shading of the classic 3D effects. use vectorcraft_geom::{Point, Vec2}; +/// The surface of a classic 3D effect: how its faces are drawn and lit. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ClassicSurface { + /// Only the outline of every face, hidden ones included; the faces are transparent. + Wireframe, + /// Every face in the art's own paint, unlit. + NoShading, + /// Soft light: each face as bright as it faces the light. + Diffuse, + /// Diffuse light plus a glossy highlight where a face mirrors the light toward the viewer. + #[default] + Plastic, +} + +impl ClassicSurface { + pub const ALL: [ClassicSurface; 4] = [Self::Wireframe, Self::NoShading, Self::Diffuse, Self::Plastic]; + + pub fn id(self) -> &'static str { + match self { + Self::Wireframe => "wireframe", + Self::NoShading => "none", + Self::Diffuse => "diffuse", + Self::Plastic => "plastic", + } + } + + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|v| v.id() == s) + } +} + +/// The surface settings of a classic 3D effect. Shares are 0..1. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ClassicShading { + pub surface: ClassicSurface, + /// Light every face gets, whichever way it faces. + pub ambient: f64, + /// How bright the highlight is at its center (Plastic only). + pub highlight_intensity: f64, + /// How wide the highlight spreads: 1 broad, 0 a pinpoint (Plastic only). + pub highlight_size: f64, + /// How many levels the shading and the highlight are rounded to, 1..256. + pub blend_steps: u32, +} + +impl Default for ClassicShading { + fn default() -> Self { + Self { surface: ClassicSurface::Plastic, ambient: 0.5, highlight_intensity: 0.6, highlight_size: 0.9, blend_steps: 25 } + } +} + +impl ClassicShading { + /// The shine exponent of the highlight: 2 at the largest size, 256 at the smallest. + fn shininess(self) -> f64 { + 2f64.powf(1.0 + 7.0 * (1.0 - self.highlight_size.clamp(0.0, 1.0))) + } + + /// `x` (0..1) rounded to the nearest of the blend steps' levels. + fn stepped(self, x: f64) -> f64 { + let steps = f64::from(self.blend_steps.clamp(1, 256)); + ((x.clamp(0.0, 1.0) * steps).round() / steps).clamp(0.0, 1.0) + } + + /// The tone (0 in full shadow, 1 fully lit) and the highlight (0 none, 1 white) of a face with + /// view-space unit normal `normal`, seen from the front and lit by `lights`: each a unit + /// direction toward the light and its intensity (0..1). The lights add up. Wireframe and No + /// Shading are unlit: tone 1, no highlight. + pub fn tone(self, normal: [f64; 3], lights: &[([f64; 3], f64)]) -> (f64, f64) { + if matches!(self.surface, ClassicSurface::Wireframe | ClassicSurface::NoShading) { + return (1.0, 0.0); + } + let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; + let (mut tone, mut spec) = (self.ambient.clamp(0.0, 1.0), 0.0); + for &(light, intensity) in lights { + let intensity = if intensity.is_finite() { intensity.clamp(0.0, 1.0) } else { 0.0 }; + let lit = dot(normal, light).max(0.0); + tone += intensity * lit; + // The halfway direction between the light and the eye in front of the page. + let half = [light[0], light[1], light[2] + 1.0]; + let length = dot(half, half).sqrt(); + if self.surface == ClassicSurface::Plastic && length > 1e-12 && lit > 0.0 { + spec += intensity * (dot(normal, half) / length).max(0.0).powf(self.shininess()) * self.highlight_intensity.clamp(0.0, 1.0); + } + } + if !tone.is_finite() || !spec.is_finite() { + return (1.0, 0.0); + } + (self.stepped(tone), self.stepped(spec)) + } +} + /// Document-space gradient placement and the brightness at its two ends. Clipping a /// face must keep this placement unchanged, so newly cut edges retain their lighting. #[derive(Clone, Copy, Debug, PartialEq)] diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index e51e259c4..4d6e78188 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -205,11 +205,14 @@ pub struct Extrude { /// Closed ends (a solid) or open ones (a hollow tube). pub cap: bool, pub bevel: Option, + /// Keep the faces of a closed solid that are turned away from the viewer (Draw Hidden Faces, + /// and the classic Wireframe surface), drawn behind the faces turned toward it. + pub hidden: bool, } impl Default for Extrude { fn default() -> Self { - Self { depth: 50.0, twist: 0.0, taper: 1.0, cap: true, bevel: None } + Self { depth: 50.0, twist: 0.0, taper: 1.0, cap: true, bevel: None, hidden: false } } } @@ -243,6 +246,9 @@ pub struct Facet { pub depth: f64, pub brightness: f64, pub illumination: [f64; 3], + /// The face's unit normal in view space (x right, y down, z toward the viewer), turned to the + /// side the viewer sees. + pub normal: [f64; 3], /// Smooth lighting on an inflated triangle, before clipping individual RGB channels. pub rgb_gradient: Option, /// The contours in view space, before projection, for tests that measure depths. diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index c6a8fc7a9..9948c4796 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -54,6 +54,9 @@ pub(super) struct Builder<'a> { /// A closed solid hides the faces turned away from the viewer; an open one shows their /// insides. pub(super) closed: bool, + /// A closed solid keeps the faces turned away from the viewer too, with their outward normals, + /// drawn behind the others. + pub(super) hidden: bool, pub(super) facets: Vec, /// With `Some`, every face goes here as it is, none hidden or projected, for a mesh. pub(super) mesh: Option>, @@ -99,7 +102,7 @@ impl Builder<'_> { // leave gaps. let (n, behind) = if facing > 0.0 { (n, false) - } else if self.closed && facing > -0.02 { + } else if self.closed && (facing > -0.02 || self.hidden) { (n, true) } else if self.closed { return Ok(()); @@ -127,6 +130,7 @@ impl Builder<'_> { depth, brightness: self.camera.brightness(n), illumination: crate::illumination(&self.camera.view, n, self.camera.light), + normal: [n.0, n.1, n.2], rgb_gradient: None, #[cfg(test)] view, @@ -405,7 +409,7 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio } let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; let camera = Camera::new(view, bounds, o.depth); - let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![], mesh: mesh.then(Vec::new) }; + let mut b = Builder { camera: &camera, closed: o.cap, hidden: o.hidden, facets: vec![], mesh: mesh.then(Vec::new) }; let steps = steps(&o); let z = |t: f64| o.depth / 2.0 - o.depth * t; let ring_at = @@ -463,7 +467,7 @@ pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: V check_bounds(bounds)?; let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?; let camera = Camera::new(view, bounds, 0.0); - let mut b = Builder { camera: &camera, closed: false, facets: vec![], mesh: mesh.then(Vec::new) }; + let mut b = Builder { camera: &camera, closed: false, hidden: false, facets: vec![], mesh: mesh.then(Vec::new) }; let contours: Vec> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect(); // A plane is one flat face, seen from either side. Its normal is turned toward the viewer, so // it is drawn last, as a cap is, and planes drawn in one order keep the order they are given in. diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index 984017593..51f9af911 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -125,7 +125,7 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo let smooth = view.shade && view.has_colored_lights(); let camera = Camera::new(view, bounds, o.depth + 2.0 * height.max(reach)); // Without its back cap the object is a shell open behind, its inside showing. - let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![], mesh: mesh.then(Vec::new) }; + let mut b = Builder { camera: &camera, closed: o.cap, hidden: false, facets: vec![], mesh: mesh.then(Vec::new) }; let half = o.depth / 2.0; // The back: turned and scaled by the twist and taper, its puff too. let back_turn = Turn::new(bounds, o.twist, o.taper, 1.0); diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index c48b8f276..46d178287 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -76,6 +76,28 @@ fn no_depth_is_the_flat_shape_and_no_cap_shows_the_inside() { assert!(hollow.iter().all(|f| f.contours.len() == 1 && f.contours[0].len() == 4)); } +#[test] +fn hidden_faces_draw_every_face_of_the_solid_behind_the_visible_ones() { + let (p, b) = shape(&square(0.0, 0.0, 100.0)); + let v = View { rotation_x: 20.0, rotation_y: 30.0, ..front() }; + let shown = extrude(&p, FillRule::EvenOdd, b, Extrude::default(), v).unwrap(); + let all = extrude(&p, FillRule::EvenOdd, b, Extrude { hidden: true, ..Extrude::default() }, v).unwrap(); + // A box: the three faces turned to the viewer, then the three turned away, drawn first. + assert_eq!(shown.len(), 3); + assert_eq!(all.len(), 6); + assert!(all.windows(2).all(|w| w[0].depth <= w[1].depth)); + // The faces turned away keep their outward normals, so a light behind the solid lights them. + assert!(all[..3].iter().all(|f| f.normal[2] < 0.0), "{:?}", all.iter().map(|f| f.normal).collect::>()); + for f in shown.iter().chain(&all) { + let [x, y, z] = f.normal; + assert!(close(x.hypot(y).hypot(z), 1.0, 1e-9), "{:?}", f.normal); + } + // The visible faces keep their normals and their place at the front. + for (front, kept) in shown.iter().rev().zip(all.iter().rev()) { + assert_eq!(front.normal, kept.normal); + } +} + #[test] fn taper_shrinks_the_back_and_twist_turns_it() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); @@ -232,6 +254,7 @@ fn malformed_and_degenerate_input_is_bounded() { taper: -3.0, cap: true, bevel: Some(Bevel { width: f64::NAN, height: 1e12, repeat: usize::MAX, space: 7.0, ..Bevel::default() }), + hidden: false, }; let f = extrude(&p, FillRule::EvenOdd, b, wild, View { rotation_x: f64::NAN, perspective: 900.0, ..front() }).unwrap(); assert!(f.iter().flat_map(|f| f.contours.iter().flatten()).all(|q| q.x.is_finite() && q.y.is_finite())); diff --git a/crates/three-d/src/tests.rs b/crates/three-d/src/tests.rs index a164c08fe..e2b6985fb 100644 --- a/crates/three-d/src/tests.rs +++ b/crates/three-d/src/tests.rs @@ -1,6 +1,7 @@ use super::*; mod caps; +mod classic; mod lighting; mod multiple_lights; mod smooth; diff --git a/crates/three-d/src/tests/classic.rs b/crates/three-d/src/tests/classic.rs new file mode 100644 index 000000000..1d807b4a3 --- /dev/null +++ b/crates/three-d/src/tests/classic.rs @@ -0,0 +1,96 @@ +use crate::*; + +const TOWARD: [f64; 3] = [0.0, 0.0, 1.0]; + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 +} + +fn one(light: [f64; 3], intensity: f64) -> [([f64; 3], f64); 1] { + [(light, intensity)] +} + +#[test] +fn wireframe_and_no_shading_leave_every_face_unlit() { + for surface in [ClassicSurface::Wireframe, ClassicSurface::NoShading] { + let s = ClassicShading { surface, ..ClassicShading::default() }; + for normal in [TOWARD, [1.0, 0.0, 0.0], [0.0, 0.0, -1.0]] { + assert_eq!(s.tone(normal, &one(TOWARD, 1.0)), (1.0, 0.0), "{surface:?}"); + } + } +} + +#[test] +fn diffuse_adds_the_light_to_the_ambient_and_has_no_highlight() { + let s = ClassicShading { surface: ClassicSurface::Diffuse, ambient: 0.3, blend_steps: 256, ..ClassicShading::default() }; + let (lit, spec) = s.tone(TOWARD, &one(TOWARD, 0.4)); + assert!((lit - 0.7).abs() < 1.0 / 256.0, "{lit}"); + assert_eq!(spec, 0.0); + // A face turned away from the light gets the ambient light only. + let (away, _) = s.tone(TOWARD, &one([1.0, 0.0, 0.0], 0.4)); + assert!((away - 0.3).abs() < 1.0 / 256.0, "{away}"); + // A face at 60° to the light gets half of it. + let (half, _) = s.tone(TOWARD, &one([3f64.sqrt() / 2.0, 0.0, 0.5], 0.4)); + assert!((half - 0.5).abs() < 1.0 / 256.0, "{half}"); + // The tone stays within full shadow and full light. + assert_eq!(ClassicShading { ambient: 1.0, ..s }.tone(TOWARD, &one(TOWARD, 1.0)).0, 1.0); + // Without lights a face gets the ambient light only. + assert!((s.tone(TOWARD, &[]).0 - 0.3).abs() < 1.0 / 256.0); +} + +#[test] +fn lights_add_up() { + let s = ClassicShading { surface: ClassicSurface::Diffuse, ambient: 0.0, blend_steps: 256, ..ClassicShading::default() }; + let left = [-0.6, 0.0, 0.8]; + let right = [0.6, 0.0, 0.8]; + let both = s.tone(TOWARD, &[(left, 0.3), (right, 0.3)]).0; + let single = s.tone(TOWARD, &one(left, 0.3)).0; + assert!((both - 2.0 * single).abs() < 2.0 / 256.0, "{both} {single}"); + // A light behind the object lights the faces turned away from the viewer only. + assert_eq!(s.tone(TOWARD, &one([0.0, 0.0, -1.0], 1.0)).0, 0.0); + assert!(s.tone([0.0, 0.0, -1.0], &one([0.0, 0.0, -1.0], 1.0)).0 > 0.99); +} + +#[test] +fn plastic_highlight_peaks_where_a_face_mirrors_the_light_to_the_eye() { + let s = ClassicShading { highlight_intensity: 0.8, blend_steps: 256, ..ClassicShading::default() }; + let light = one([0.6, 0.0, 0.8], 1.0); + // The face halfway between the light and the eye mirrors the light straight to the viewer. + let length = 0.6f64.hypot(1.8); + let half = [0.6 / length, 0.0, 1.8 / length]; + let (_, peak) = s.tone(half, &light); + assert!((peak - 0.8).abs() < 1.0 / 256.0, "{peak}"); + let (_, aside) = s.tone([-0.6, 0.0, 0.8], &light); + assert!(aside < peak / 4.0, "{aside}"); + // A smaller highlight falls off faster. + let tilted = [0.3, 0.0, 0.954]; + let broad = s.tone(tilted, &light).1; + let narrow = ClassicShading { highlight_size: 0.1, ..s }.tone(tilted, &light).1; + assert!(narrow < broad, "{narrow} {broad}"); + // No highlight on a face the light does not reach, without Highlight Intensity, or on Diffuse. + assert_eq!(s.tone(TOWARD, &one([0.0, 0.0, -1.0], 1.0)).1, 0.0); + assert_eq!(ClassicShading { highlight_intensity: 0.0, ..s }.tone(half, &light).1, 0.0); + assert_eq!(ClassicShading { surface: ClassicSurface::Diffuse, ..s }.tone(half, &light).1, 0.0); +} + +#[test] +fn blend_steps_round_the_shading_to_that_many_levels() { + let s = ClassicShading { surface: ClassicSurface::Diffuse, ambient: 0.0, blend_steps: 2, ..ClassicShading::default() }; + let tones: Vec = (0..=20).map(|k| f64::from(k) / 20.0).map(|c| s.tone(TOWARD, &one([(1.0 - c * c).sqrt(), 0.0, c], 0.5)).0).collect(); + assert!(tones.iter().all(|t| close(*t, 0.0) || close(*t, 0.5)), "{tones:?}"); + assert!(tones.iter().any(|t| close(*t, 0.0)) && tones.iter().any(|t| close(*t, 0.5)), "{tones:?}"); + // Out-of-range step counts and intensities are kept within their ranges. + for steps in [0, 100_000] { + let t = ClassicShading { blend_steps: steps, ..s }.tone(TOWARD, &one(TOWARD, f64::NAN)).0; + assert!((0.0..=1.0).contains(&t)); + } +} + +#[test] +fn surfaces_round_trip_through_their_ids() { + for surface in ClassicSurface::ALL { + assert_eq!(ClassicSurface::parse(surface.id()), Some(surface)); + } + assert_eq!(ClassicSurface::parse("glossy"), None); + assert_eq!(ClassicSurface::default(), ClassicSurface::Plastic); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d5e2a30c6..e5a47085f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -126,6 +126,7 @@ pub mod symbolism_options; pub mod tate_chu_yoko; mod text_export; pub mod text_import; +mod three_d_classic; mod tiff_bmp_tga; pub mod tile_edge_color; mod tools; diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 324cc144a..93688542c 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -350,15 +350,20 @@ fn choices(effect: &str, key: &str) -> Option { /// The parameters whose dialogs list their values in their own bodies, as (label, value), each /// label as that dialog passes it to its dropdown or radio buttons, which translate it into the UI -/// language: Revolve's edge, Extrude & Bevel's bevel (Rounded translated in the "3d" context -/// first), every Warp style's orientation, Rasterize's Color Model (its `document` value labeled +/// language: Revolve's edge, the bevel of both Extrude & Bevels (Rounded translated in the "3d" +/// context first), Extrude & Bevel (Classic)'s surface and shading color, every Warp style's +/// orientation, Rasterize's Color Model (its `document` value labeled /// CMYK when `cmyk`, for a document in CMYK, and RGB otherwise), Background and Anti-aliasing, and /// the channel of Levels and Curves. pub(super) fn own_choices(effect: &str, key: &str, cmyk: bool) -> Option> { let c3 = |s: &'static str| crate::i18n::tr_ctx(crate::i18n::current(), "3d", s); Some(match (effect, key) { (vectorcraft_effects::REVOLVE, "edge") => vec![(tl!("Left"), "left"), (tl!("Right"), "right")], - (vectorcraft_effects::EXTRUDE, "bevel") => vec![ + (vectorcraft_effects::CLASSIC_EXTRUDE, "surface") => { + vec![(tl!("Wireframe"), "wireframe"), (tl!("No Shading"), "none"), (tl!("Diffuse"), "diffuse"), (tl!("Plastic"), "plastic")] + } + (vectorcraft_effects::CLASSIC_EXTRUDE, "shadingColor") => vec![(tl!("Black"), "black"), (tl!("Custom"), "custom"), (tl!("None"), "none")], + (vectorcraft_effects::EXTRUDE | vectorcraft_effects::CLASSIC_EXTRUDE, "bevel") => vec![ (tl!("None"), "none"), (tl!("Classic"), "classic"), (c3("Rounded"), "rounded"), @@ -919,6 +924,9 @@ mod tests { for (effect, key) in [ (vectorcraft_effects::REVOLVE, "edge"), (vectorcraft_effects::EXTRUDE, "bevel"), + (vectorcraft_effects::CLASSIC_EXTRUDE, "bevel"), + (vectorcraft_effects::CLASSIC_EXTRUDE, "surface"), + (vectorcraft_effects::CLASSIC_EXTRUDE, "shadingColor"), ("warp.arc", "orientation"), ("warp.twist", "orientation"), (vectorcraft_effects::RASTERIZE, "background"), diff --git a/crates/ui-egui/src/dialogs/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs index f3c7d7e35..6ca2e5771 100644 --- a/crates/ui-egui/src/dialogs/revolve.rs +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -1,11 +1,12 @@ -//! Compact controls for the 3D and Materials effects: Extrude & Bevel, Revolve, Inflate and Rotate. -//! The object and the rotation and light gizmos live on the artboard. All document changes run -//! through the effect dialog's existing preview/commit commands. +//! Compact controls for the 3D and Materials effects: Extrude & Bevel, Revolve, Inflate and Rotate, +//! and Extrude & Bevel (Classic), whose own controls are in `three_d_classic`. The object and the +//! rotation and light gizmos live on the artboard. All document changes run through the effect +//! dialog's existing preview/commit commands. use egui::{Align2, FontId, Id, Pos2, Sense, Stroke, vec2}; use serde_json::{Value, json}; use vectorcraft_doc::Unit; -use vectorcraft_effects::{EXTRUDE, INFLATE, REVOLVE, ROTATION_PRESETS}; +use vectorcraft_effects::{CLASSIC_EXTRUDE, EXTRUDE, INFLATE, REVOLVE, ROTATION_PRESETS}; use crate::state::Dialog; use crate::theme::Tokens; @@ -140,14 +141,15 @@ pub(crate) fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) } /// `s` as the 3D effects mean it (a solid's cap, not a stroke's; a bevelled edge, not a join). -fn c3(s: &'static str) -> &'static str { +pub(super) fn c3(s: &'static str) -> &'static str { crate::i18n::tr_ctx(crate::i18n::current(), "3d", s) } -/// Extrude & Bevel and Inflate: Object, Rotation and Lighting tabs (Rotate has no Object tab). -/// The rotation gizmo shows on the first two, the light gizmo on Lighting. +/// Extrude & Bevel, Inflate and Extrude & Bevel (Classic): Object, Rotation and Lighting tabs +/// (Rotate has no Object tab). The rotation gizmo shows on the first two, the light gizmo on +/// Lighting. fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: &str) { - let object = id == EXTRUDE || id == INFLATE; + let object = id == EXTRUDE || id == INFLATE || id == CLASSIC_EXTRUDE; let mut tabs = vec![]; if object { tabs.push(tl!("Object")); @@ -167,10 +169,16 @@ fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: & } ui.add_space(8.0); if super::revolve_gizmo::lighting(d) { - lighting_controls(ui, d); + if id == CLASSIC_EXTRUDE { + super::three_d_classic::surface_controls(ui, d); + } else { + lighting_controls(ui, d); + } } else if object && d.str("__tab") != "rotation" { let unit = app.session.general_unit(); - if id == EXTRUDE { + if id == CLASSIC_EXTRUDE { + super::three_d_classic::object_controls(ui, d, unit); + } else if id == EXTRUDE { extrude_controls(ui, d, unit); } else { heading(ui, tl!("Object")); @@ -249,7 +257,7 @@ fn view_controls(ui: &mut egui::Ui, d: &mut Dialog) { } /// A dropdown of `options` (value, label) for `key`, with its reset dot. -fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, options: &[(Value, &str)]) { +pub(super) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, options: &[(Value, &str)]) { let id = d.str("__effect"); let default = vectorcraft_effects::default_params(&id).and_then(|p| p.get(key).cloned()).unwrap_or(Value::Null); // A mixed setting (null) shows its default, with its reset dot filled. @@ -283,7 +291,7 @@ fn flag(d: &Dialog, key: &str, default: bool) -> Option { } /// A checkbox for `key`, with its reset dot. A mixed setting shows a dash, and a click turns it on. -fn toggle(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, default: bool) { +pub(super) fn toggle(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, default: bool) { ui.horizontal(|ui| { let on = flag(d, key, default); if widgets::check3(ui, name, on, ui.is_enabled()) { @@ -386,11 +394,11 @@ pub(super) fn light_options(d: &Dialog) -> vectorcraft_three_d::Revolve { vectorcraft_effects::revolve_options(&super::form::params(&selected_light(d))) } -fn light_record(d: &Dialog) -> Value { +pub(super) fn light_record(d: &Dialog) -> Value { Value::Object(LIGHT_KEYS.iter().filter_map(|(key, field)| d.fields.get(*field).map(|v| ((*key).into(), v.clone()))).collect()) } -fn store_selected(d: &mut Dialog, selected: &Dialog) { +pub(super) fn store_selected(d: &mut Dialog, selected: &Dialog) { if let Some(list) = explicit_lights(d) { let index = light_index(d, list.len()); let mut list = list.clone(); @@ -413,7 +421,7 @@ pub(super) fn set_light_value(d: &mut Dialog, key: &str, value: Value) { store_selected(d, &selected); } -fn light_list(ui: &mut egui::Ui, d: &mut Dialog) { +pub(super) fn light_list(ui: &mut egui::Ui, d: &mut Dialog) { let mut list = explicit_lights(d).cloned().unwrap_or_else(|| vec![light_record(d)]); let mut index = light_index(d, list.len()); let names: Vec<_> = (1..=list.len()).map(|i| format!("{} {i}", c3("Light"))).collect(); @@ -483,12 +491,12 @@ fn light_color(ui: &mut egui::Ui, d: &mut Dialog) { }); } -fn heading(ui: &mut egui::Ui, text: &str) { +pub(super) fn heading(ui: &mut egui::Ui, text: &str) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(text).font(crate::theme::semibold(13.0)).color(t.text)); } -fn label(ui: &mut egui::Ui, name: &str) { +pub(super) fn label(ui: &mut egui::Ui, name: &str) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(LABEL_W, 24.0), Sense::hover()); ui.put(r, egui::Label::new(egui::RichText::new(name).size(12.5).color(t.text)).halign(egui::Align::Min).truncate()).on_hover_text(name); @@ -501,7 +509,7 @@ fn rail_width(ui: &egui::Ui) -> f32 { /// A value can be typed, dragged, scrubbed by its label, or moved on its slider. #[allow(clippy::too_many_arguments)] -fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64, f64), suffix: &str, decimals: usize, unit: Option) { +pub(super) fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64, f64), suffix: &str, decimals: usize, unit: Option) { let default = vectorcraft_effects::default_params(&d.str("__effect")).and_then(|p| p.get(key).and_then(Value::as_f64)).unwrap_or(0.0); let from = |n| unit.map_or(n, |u| u.from_pt(n)); let to = |n| unit.map_or(n, |u| u.to_pt(n)); @@ -591,7 +599,7 @@ fn dot(ui: &egui::Ui, p: Pos2, changed: bool) { } } -fn reset_dot(ui: &mut egui::Ui, key: &str, changed: bool, default: &str) -> bool { +pub(super) fn reset_dot(ui: &mut egui::Ui, key: &str, changed: bool, default: &str) -> bool { let (r, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover()); let response = ui.interact(r, Id::unique(("revolve-reset", key)), Sense::click()); response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Reset to default"))); diff --git a/crates/ui-egui/src/dialogs/revolve/tests.rs b/crates/ui-egui/src/dialogs/revolve/tests.rs index bbaea5255..103fbe6ad 100644 --- a/crates/ui-egui/src/dialogs/revolve/tests.rs +++ b/crates/ui-egui/src/dialogs/revolve/tests.rs @@ -297,6 +297,68 @@ fn the_other_3d_effects_show_their_options_and_position_presets() { } } +/// Extrude & Bevel (Classic): its extrude and bevel options on the Object tab, Position on the +/// Rotation tab, and the surface with its lights, highlight, blend steps and shading color on the +/// Lighting tab; OK keeps the edit as one undo step. +#[test] +fn classic_extrude_bevel_shows_its_options_and_applies_as_one_step() { + use vectorcraft_effects::CLASSIC_EXTRUDE; + let mut h = Harness::with("M100 100 L200 100 L200 200 L100 200 Z", CLASSIC_EXTRUDE, vec2(1400.0, 1000.0)); + let before = h.app.session.doc().unwrap().doc.clone(); + h.settle(); + for text in ["Depth", "Cap", "Bevel Shape", "Height", "Bevel Inside"] { + h.rect(text); + } + assert!(!h.texts.iter().any(|(t, _)| t == "Twist" || t == "Taper" || t == "Width"), "the newer effect's options stay out"); + assert!(!h.ctx.read_response(Id::unique(("revolve-reset", "bevelHeight"))).unwrap().enabled(), "no bevel: its height is off"); + h.app.ui.dialog.as_mut().unwrap().fields.insert("__tab".into(), json!("rotation")); + h.settle(); + for text in ["Presets", "Rotation X", "Perspective", "Off-Axis Front"] { + h.rect(text); + } + h.click(h.rect("Lighting").center()); + h.settle(); + for text in [ + "Surface", + "Plastic", + "Light Intensity", + "Ambient Light", + "Highlight Intensity", + "Highlight Size", + "Blend Steps", + "Shading Color", + "Draw Hidden Faces", + ] { + h.rect(text); + } + assert!(h.ctx.read_response(Id::unique(("revolve-reset", "highlightSize"))).unwrap().enabled()); + // The light gizmo is on for a lit surface. + assert_eq!(dialogs::revolve_gizmo::hint(h.app.ui.dialog.as_ref().unwrap()), "Drag the light, or its rings. Shift snaps to 15°."); + // Diffuse has no highlight; Wireframe and No Shading take no light. + h.app.ui.dialog.as_mut().unwrap().fields.insert("surface".into(), json!("diffuse")); + h.settle(); + assert!(!h.ctx.read_response(Id::unique(("revolve-reset", "highlightSize"))).unwrap().enabled()); + assert!(h.ctx.read_response(Id::unique(("revolve-reset", "ambient"))).unwrap().enabled()); + h.app.ui.dialog.as_mut().unwrap().fields.insert("surface".into(), json!("wireframe")); + h.settle(); + assert!(!h.ctx.read_response(Id::unique(("revolve-reset", "ambient"))).unwrap().enabled()); + assert!(h.ctx.read_response(Id::unique(("revolve-reset", "drawHiddenFaces"))).unwrap().enabled()); + assert_eq!(dialogs::revolve_gizmo::hint(h.app.ui.dialog.as_ref().unwrap()), "Drag values or labels. Double-click a value to type."); + // A custom shading color shows its color row. + let d = h.app.ui.dialog.as_mut().unwrap(); + d.fields.insert("surface".into(), json!("diffuse")); + d.fields.insert("shadingColor".into(), json!("custom")); + h.settle(); + h.rect("#000000"); + dialogs::confirm(&mut h.app).unwrap(); + let state = h.app.session.doc().unwrap(); + let fx = &state.doc.node(state.selection.objects[0]).unwrap().appearance.effects[0]; + assert_eq!(fx.id, CLASSIC_EXTRUDE); + assert_eq!((fx.params["surface"].clone(), fx.params["shadingColor"].clone()), (json!("diffuse"), json!("custom"))); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc().unwrap().doc, before); +} + #[test] fn extrude_bevel_options_enable_with_a_bevel_shape() { let mut h = Harness::with("M100 100 L200 100 L200 200 L100 200 Z", vectorcraft_effects::EXTRUDE, vec2(1400.0, 1000.0)); diff --git a/crates/ui-egui/src/dialogs/revolve_gizmo.rs b/crates/ui-egui/src/dialogs/revolve_gizmo.rs index c9194d5d0..6ef51d38e 100644 --- a/crates/ui-egui/src/dialogs/revolve_gizmo.rs +++ b/crates/ui-egui/src/dialogs/revolve_gizmo.rs @@ -20,8 +20,18 @@ pub(crate) fn lighting(d: &Dialog) -> bool { d.str("__gizmo") == "light" } +/// Does the effect take light: Shading on, or for Extrude & Bevel (Classic) a Diffuse or Plastic +/// surface? +fn lit(d: &Dialog) -> bool { + if d.str("__effect") == vectorcraft_effects::CLASSIC_EXTRUDE { + matches!(d.str("surface").as_str(), "diffuse" | "plastic") + } else { + d.bool("shade") + } +} + pub(crate) fn hint(d: &Dialog) -> &'static str { - if !d.bool("preview") || (lighting(d) && !d.bool("shade")) { + if !d.bool("preview") || (lighting(d) && !lit(d)) { tl!("Drag values or labels. Double-click a value to type.") } else if lighting(d) { tl!("Drag the light, or its rings. Shift snaps to 15°.") @@ -190,7 +200,7 @@ fn snap_height(height: f64, snap: bool) -> f64 { pub(super) fn show(app: &VectorcraftApp, ctx: &egui::Context, d: &mut Dialog) { let light_mode = lighting(d); - if !d.bool("preview") || (light_mode && !d.bool("shade")) { + if !d.bool("preview") || (light_mode && !lit(d)) { clear(ctx); return; } diff --git a/crates/ui-egui/src/dialogs/three_d_classic.rs b/crates/ui-egui/src/dialogs/three_d_classic.rs new file mode 100644 index 000000000..06d034fce --- /dev/null +++ b/crates/ui-egui/src/dialogs/three_d_classic.rs @@ -0,0 +1,85 @@ +//! Extrude & Bevel (Classic)'s own controls in the compact 3D dialog: the extrude and bevel options +//! on the Object tab, and the surface with its lights, highlight, blend steps and shading color on +//! the Lighting tab. Position (presets, rotation, perspective) is the Rotation tab every 3D effect +//! shares. + +use serde_json::{Value, json}; +use vectorcraft_doc::Unit; +use vectorcraft_effects::CLASSIC_EXTRUDE; + +use super::revolve::{c3, choice, heading, label, light_list, light_record, number, reset_dot, selected_light, store_selected, toggle}; +use crate::state::Dialog; + +/// Depth and cap, then the bevel: its shape, its height and whether it is added around the shape +/// or carved into it. +pub(super) fn object_controls(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { + heading(ui, tl!("Object")); + number(ui, d, "depth", tl!("Depth"), (0.0, 2000.0), "", 1, Some(unit)); + choice(ui, d, "cap", c3("Cap"), &[(json!(true), c3("Solid")), (json!(false), c3("Hollow"))]); + ui.add_space(10.0); + heading(ui, c3("Bevel")); + choice(ui, d, "bevel", tl!("Bevel Shape"), &options("bevel")); + ui.add_enabled_ui(d.str("bevel") != "none", |ui| { + number(ui, d, "bevelHeight", tl!("Height"), (1.0, 100.0), "", 1, Some(unit)); + toggle(ui, d, "bevelInside", tl!("Bevel Inside"), false); + }); +} + +/// The surface, then for Diffuse and Plastic the lights, Ambient Light, the highlight (Plastic +/// only), Blend Steps and Shading Color; Draw Hidden Faces last. +pub(super) fn surface_controls(ui: &mut egui::Ui, d: &mut Dialog) { + choice(ui, d, "surface", tl!("Surface"), &options("surface")); + let surface = d.str("surface"); + ui.add_enabled_ui(surface == "diffuse" || surface == "plastic", |ui| { + light_list(ui, d); + let mut selected = selected_light(d); + let before = light_record(&selected); + number(ui, &mut selected, "lightIntensity", tl!("Light Intensity"), (0.0, 100.0), "%", 1, None); + number(ui, &mut selected, "lightRotation", tl!("Rotation"), (-180.0, 180.0), "°", 1, None); + number(ui, &mut selected, "lightHeight", tl!("Height"), (0.0, 90.0), "°", 1, None); + toggle(ui, &mut selected, "lightBehind", tl!("Light behind the object"), false); + if light_record(&selected) != before { + store_selected(d, &selected); + } + number(ui, d, "ambient", tl!("Ambient Light"), (0.0, 100.0), "%", 1, None); + ui.add_enabled_ui(surface == "plastic", |ui| { + number(ui, d, "highlightIntensity", tl!("Highlight Intensity"), (0.0, 100.0), "%", 1, None); + number(ui, d, "highlightSize", tl!("Highlight Size"), (0.0, 100.0), "%", 1, None); + }); + number(ui, d, "blendSteps", tl!("Blend Steps"), (1.0, 256.0), "", 0, None); + choice(ui, d, "shadingColor", tl!("Shading Color"), &options("shadingColor")); + if d.str("shadingColor") == "custom" { + shading_custom(ui, d); + } + }); + toggle(ui, d, "drawHiddenFaces", tl!("Draw Hidden Faces"), false); +} + +/// The values of Extrude & Bevel (Classic)'s `key` as the dropdowns list them, (value, label). +fn options(key: &str) -> Vec<(Value, &'static str)> { + super::effect::own_choices(CLASSIC_EXTRUDE, key, false).unwrap_or_default().into_iter().map(|(label, value)| (json!(value), label)).collect() +} + +/// The custom shading color: a color button, its hex value and its reset dot. +fn shading_custom(ui: &mut egui::Ui, d: &mut Dialog) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + label(ui, tl!("Color")); + let color = d + .fields + .get("shadingCustom") + .and_then(Value::as_str) + .filter(|s| s.len() == 7 && s.starts_with('#')) + .and_then(vectorcraft_color::Color::from_hex) + .unwrap_or(vectorcraft_color::Color::BLACK); + let mut rgb = color.to_rgb_uncalibrated().map(|c| (c * 255.0).round() as u8); + if ui.color_edit_button_srgb(&mut rgb).on_hover_text(tl!("Shading Color")).changed() { + d.fields.insert("shadingCustom".into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))); + } + ui.label(format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2])); + ui.add_space((ui.available_width() - 24.0).max(0.0)); + if reset_dot(ui, "shadingCustom", rgb != [0; 3], "#000000") { + d.fields.insert("shadingCustom".into(), json!("#000000")); + } + }); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d58c52014..a849c4abc 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1102,6 +1102,17 @@ font-technology Variable Променливи Exporting {} Експортиране на {} Export… Експортиране… Extrude & Bevel… Избутване и фасетка… + 3D (Classic) 3D (класически) + Extrude & Bevel (Classic)… Избутване и фасетка (класически)… + Surface Повърхност + Wireframe Телена рамка + No Shading Без засенчване + Plastic Пластмаса + Highlight Intensity Интензивност на отблясъка + Highlight Size Размер на отблясъка + Blend Steps Стъпки на преливане + Shading Color Цвят на засенчването + Draw Hidden Faces Изчертаване на скритите страни Eyedropper Пипета Eyedropper Applies: Пипетата прилага: Eyedropper Options Опции за пипетата diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index b172afb94..69a6d5e52 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -466,6 +466,17 @@ Inflate Napuhni Select objects, then pick an object type to give them a 3D effect. Odaberite objekte, zatim odaberite vrstu objekta da im date 3D efekat. Extrude & Bevel… Istiskivanje i zakošenje… + 3D (Classic) 3D (klasično) + Extrude & Bevel (Classic)… Istiskivanje i zakošenje (klasično)… + Surface Površina + Wireframe Žičani model + No Shading Bez sjenčenja + Plastic Plastika + Highlight Intensity Intenzitet odsjaja + Highlight Size Veličina odsjaja + Blend Steps Koraci prelaza + Shading Color Boja sjenčenja + Draw Hidden Faces Crtaj skrivene strane Revolve… Rotaciono tijelo… Inflate… Napuhni… Materials… Materijali… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 70d223aa4..4d7615e37 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1518,6 +1518,17 @@ antiAlias Sharp Nítid Exporting {} S’està exportant {} Export… Exporta… Extrude & Bevel… Extrudeix i bisella… + 3D (Classic) 3D (clàssic) + Extrude & Bevel (Classic)… Extrudeix i bisella (clàssic)… + Surface Superfície + Wireframe Filferro + No Shading Sense ombra + Plastic Plàstic + Highlight Intensity Intensitat de la brillantor + Highlight Size Mida de la brillantor + Blend Steps Passos de fusió + Shading Color Color de l’ombra + Draw Hidden Faces Dibuixa les cares ocultes Eyedropper Comptagotes Eyedropper Applies: El comptagotes aplica: Eyedropper Options Opcions del comptagotes diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7253c384d..725ad5b7b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -475,6 +475,17 @@ Inflate Nafouknutí Select objects, then pick an object type to give them a 3D effect. Vyberte objekty a pak zvolte typ objektu, abyste jim dali 3D efekt. Extrude & Bevel… Vysunutí a zkosení… + 3D (Classic) 3D (klasické) + Extrude & Bevel (Classic)… Vysunutí a zkosení (klasické)… + Surface Povrch + Wireframe Drátový model + No Shading Bez stínování + Plastic Plast + Highlight Intensity Intenzita odlesku + Highlight Size Velikost odlesku + Blend Steps Kroky prolnutí + Shading Color Barva stínování + Draw Hidden Faces Kreslit skryté stěny Revolve… Rotační těleso… Inflate… Nafouknutí… Materials… Materiály… diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index e5f51f4d5..8174bad84 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -433,6 +433,17 @@ Inflate Pust op Select objects, then pick an object type to give them a 3D effect. Markér objekter, og vælg derefter en objekttype for at give dem en 3D-effekt. Extrude & Bevel… Ekstrudér og facet… + 3D (Classic) 3D (klassisk) + Extrude & Bevel (Classic)… Ekstrudér og facet (klassisk)… + Surface Overflade + Wireframe Trådmodel + No Shading Ingen skyggelægning + Plastic Plastik + Highlight Intensity Højlysintensitet + Highlight Size Højlysstørrelse + Blend Steps Overgangstrin + Shading Color Skyggefarve + Draw Hidden Faces Tegn skjulte flader Revolve… Omdrej… Inflate… Pust op… Materials… Materialer… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b2e5b2648..0456b2729 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1107,6 +1107,17 @@ font-technology Variable Variabel Exporting {} {} wird exportiert Export… Exportieren… Extrude & Bevel… Extrudieren und abgeflachte Kante… + 3D (Classic) 3D (klassisch) + Extrude & Bevel (Classic)… Extrudieren und abgeflachte Kante (klassisch)… + Surface Oberfläche + Wireframe Drahtmodell + No Shading Keine Schattierung + Plastic Kunststoff + Highlight Intensity Glanzpunktintensität + Highlight Size Glanzpunktgröße + Blend Steps Angleichungsstufen + Shading Color Schattierungsfarbe + Draw Hidden Faces Verdeckte Flächen zeichnen Eyedropper Pipette Sample Color from Screen Farbe vom Bildschirm aufnehmen This system has no screen color sampler Dieses System hat keine Farbaufnahme vom Bildschirm diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 99f065c1a..818791452 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1080,6 +1080,17 @@ font-technology Variable Μεταβλητές Exporting {} Εξαγωγή {} Export… Εξαγωγή… Extrude & Bevel… Εξώθηση και λοξότμηση… + 3D (Classic) 3D (κλασικό) + Extrude & Bevel (Classic)… Εξώθηση και λοξότμηση (κλασικό)… + Surface Επιφάνεια + Wireframe Συρματόπλεγμα + No Shading Χωρίς σκίαση + Plastic Πλαστικό + Highlight Intensity Ένταση ανταύγειας + Highlight Size Μέγεθος ανταύγειας + Blend Steps Βήματα ανάμειξης + Shading Color Χρώμα σκίασης + Draw Hidden Faces Σχεδίαση κρυφών εδρών Eyedropper Σταγονόμετρο (Eyedropper Tool) Eyedropper Applies: Το σταγονόμετρο εφαρμόζει: Eyedropper Options Επιλογές σταγονόμετρου diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f88b1947a..34ebacc0c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1136,6 +1136,17 @@ font-technology Variable Variables Exporting {} Exportando {} Export… Exportar… Extrude & Bevel… Extrusión y biselado… + 3D (Classic) 3D (clásico) + Extrude & Bevel (Classic)… Extrusión y biselado (clásico)… + Surface Superficie + Wireframe Malla + No Shading Sin sombreado + Plastic Plástico + Highlight Intensity Intensidad del brillo + Highlight Size Tamaño del brillo + Blend Steps Pasos de fusión + Shading Color Color de sombreado + Draw Hidden Faces Dibujar caras ocultas Eyedropper Cuentagotas Sample Color from Screen Tomar color de la pantalla This system has no screen color sampler Este sistema no tiene muestreador de color de la pantalla diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index a1513626a..8e28fed3d 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -472,6 +472,17 @@ Inflate Täytä ilmalla Select objects, then pick an object type to give them a 3D effect. Valitse objektit ja valitse sitten objektityyppi, niin niille tulee 3D-tehoste. Extrude & Bevel… Pursota ja viistä… + 3D (Classic) 3D (perinteinen) + Extrude & Bevel (Classic)… Pursota ja viistä (perinteinen)… + Surface Pinta + Wireframe Rautalanka + No Shading Ei varjostusta + Plastic Muovi + Highlight Intensity Kiillon voimakkuus + Highlight Size Kiillon koko + Blend Steps Sulautusvaiheet + Shading Color Varjostusväri + Draw Hidden Faces Piirrä piilotetut pinnat Revolve… Pyöräytä… Inflate… Täytä ilmalla… Materials… Materiaalit… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index b820e2deb..3682c6216 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1131,6 +1131,17 @@ font-technology Variable Variables Exporting {} Exportation de {} Export… Exporter… Extrude & Bevel… Extrusion et biseautage… + 3D (Classic) 3D (classique) + Extrude & Bevel (Classic)… Extrusion et biseautage (classique)… + Surface Surface + Wireframe Fil de fer + No Shading Sans ombrage + Plastic Plastique + Highlight Intensity Intensité de la brillance + Highlight Size Taille de la brillance + Blend Steps Étapes de transition + Shading Color Couleur d’ombrage + Draw Hidden Faces Dessiner les faces cachées Eyedropper Pipette Sample Color from Screen Prélever une couleur à l’écran This system has no screen color sampler Ce système n’a pas d’outil de prélèvement de couleur à l’écran diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 241b47e57..5318523aa 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1688,6 +1688,17 @@ antiAlias Sharp शार्प Extension Lines एक्सटेंशन लाइन Extension: विस्तार: Extrude & Bevel… एक्सट्रूडेड और बेवेल… + 3D (Classic) 3D (क्लासिक) + Extrude & Bevel (Classic)… एक्सट्रूडेड और बेवेल (क्लासिक)… + Surface सतह + Wireframe वायरफ़्रेम + No Shading कोई शेडिंग नहीं + Plastic प्लास्टिक + Highlight Intensity हाइलाइट तीव्रता + Highlight Size हाइलाइट आकार + Blend Steps ब्लेंड चरण + Shading Color शेडिंग रंग + Draw Hidden Faces छिपे हुए फलक बनाएँ Eyedropper आईड्रॉपर Eyedropper Applies: आईड्रॉपपर लागू होता है: Eyedropper Options आईड्रॉपर ऑप्शन्स diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 69f3e3cab..717cc43eb 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1420,6 +1420,17 @@ font-technology Variable Változó Exporting {} Exportálás: {} Export… Exportálás… Extrude & Bevel… Kihúzás és élletörés… + 3D (Classic) 3D (klasszikus) + Extrude & Bevel (Classic)… Kihúzás és élletörés (klasszikus)… + Surface Felület + Wireframe Drótváz + No Shading Nincs árnyékolás + Plastic Műanyag + Highlight Intensity Csillanás erőssége + Highlight Size Csillanás mérete + Blend Steps Átmenetlépések + Shading Color Árnyékolás színe + Draw Hidden Faces Rejtett lapok rajzolása Eyedropper Pipetta Sample Color from Screen Szín mintavétele a képernyőről This system has no screen color sampler Ezen a rendszeren nincs képernyős színmintavevő diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 5fb92d1b0..e0d50d0e1 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -433,6 +433,17 @@ Inflate Gelembungkan Select objects, then pick an object type to give them a 3D effect. Pilih objek, lalu pilih jenis objek untuk memberinya efek 3D. Extrude & Bevel… Ekstrusi & bevel… + 3D (Classic) 3D (klasik) + Extrude & Bevel (Classic)… Ekstrusi & bevel (klasik)… + Surface Permukaan + Wireframe Kerangka kawat + No Shading Tanpa pengarsiran + Plastic Plastik + Highlight Intensity Intensitas kilau + Highlight Size Ukuran kilau + Blend Steps Langkah campuran + Shading Color Warna pengarsiran + Draw Hidden Faces Gambar sisi tersembunyi Revolve… Revolusi… Inflate… Gelembungkan… Materials… Material… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 064fd688b..c21aef108 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1137,6 +1137,17 @@ font-technology Variable Variabili Exporting {} Esportazione di {} in corso Export… Esporta… Extrude & Bevel… Estrusione e smusso… + 3D (Classic) 3D (classico) + Extrude & Bevel (Classic)… Estrusione e smusso (classico)… + Surface Superficie + Wireframe Reticolo + No Shading Nessuna ombreggiatura + Plastic Plastica + Highlight Intensity Intensità lucentezza + Highlight Size Dimensione lucentezza + Blend Steps Passaggi di fusione + Shading Color Colore ombreggiatura + Draw Hidden Faces Disegna facce nascoste Eyedropper Contagocce Sample Color from Screen Campiona colore dallo schermo This system has no screen color sampler Questo sistema non ha un campionatore di colore dello schermo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 4e9d1c0b9..131ce2bea 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -506,6 +506,17 @@ digits Malayalam マラヤーラム数字 Inflate 膨張 Select objects, then pick an object type to give them a 3D effect. オブジェクトを選択してから、オブジェクトの種類を選んで 3D 効果を適用してください。 Extrude & Bevel… 押し出しとベベル… + 3D (Classic) 3D(クラシック) + Extrude & Bevel (Classic)… 押し出しとベベル(クラシック)… + Surface 表面 + Wireframe ワイヤーフレーム + No Shading 陰影なし + Plastic プラスチック + Highlight Intensity ハイライトの強さ + Highlight Size ハイライトのサイズ + Blend Steps ブレンドの階調 + Shading Color 陰影のカラー + Draw Hidden Faces 隠れた面を描画 Revolve… 回転体… Inflate… 膨張… Materials… マテリアル… diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index a7ad2fe36..6ef5eb5e4 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -74,7 +74,7 @@ CMYK Color CMYK ფერი RGB Color RGB ფერი File Info… ფაილის ინფორმაცია… - Print… დაბეჭდვა… + Print… ბეჭდვა… Edit რედაქტირება Undo დაბრუნება Redo ხელახლა შესრულება @@ -398,6 +398,17 @@ Inflate გაბერვა Select objects, then pick an object type to give them a 3D effect. აირჩიეთ ობიექტები, შემდეგ აირჩიეთ ობიექტის ტიპი, რომ მათ 3D ეფექტი მიეცეთ. Extrude & Bevel… ამოწევა და წაკვეთა… + 3D (Classic) 3D (კლასიკური) + Extrude & Bevel (Classic)… ამოწევა და წაკვეთა (კლასიკური)… + Surface ზედაპირი + Wireframe მავთულის ჩარჩო + No Shading დაჩრდილვის გარეშე + Plastic პლასტიკური + Highlight Intensity ელვარების ინტენსივობა + Highlight Size ელვარების ზომა + Blend Steps შერევის საფეხურები + Shading Color დაჩრდილვის ფერი + Draw Hidden Faces დამალული წახნაგების დახატვა Revolve… ბრუნვა… Inflate… გაბერვა… Materials… მასალები… @@ -1259,7 +1270,7 @@ lighting Light სინათლე Dashed ტირეებიანი Delete Selected Item მონიშნული ელემენტის წაშლა Double-click to edit the characters' paint ორჯერ დაწკაპეთ სიმბოლოების შეღებვის რედაქტირებისთვის - Click to edit დააწკაპეთ რედაქტირებისთვის + Click to edit დაწკაპეთ დასარედაქტირებლად Double-click to edit ორჯერ დაწკაპეთ რედაქტირებისთვის Double-click to target the contents ორჯერ დაწკაპეთ შიგთავსის ასარჩევად Drag onto art to apply this appearance ჩაავლეთ ნახატზე ამ იერსახის გამოსაყენებლად @@ -3010,7 +3021,7 @@ theme Appearance იერსახე Reverse Order რიგის შებრუნება Show All Layers ყველა შრის ჩვენება Unlock All Layers ყველა შრის განბლოკვა - English: USA English: USA + English: USA ინგლისური: აშშ Move გადატანა Character Style სიმბოლოს სტილი Paragraph Style აბზაცის სტილი @@ -3427,7 +3438,7 @@ effect Points წერტილები Discard Empty Type ცარიელი ტექსტის მოცილება Copyright საავტორო უფლება Other სხვა - Print დაბეჭდვა + Print ბეჭდვა Solid მთლიანი Difference სხვაობა Flatten Transparency გამჭვირვალობის გაბრტყელება @@ -3663,7 +3674,7 @@ font-technology Variable ცვლადი Flip: არეკვლა: Spacing: ინტერვალი: Output გამოტანა - Export… ექსპორტი… + Export… ექსპორტირება… Import… იმპორტი… Load Profile… პროფილის ჩატვირთვა… Embedded ჩაშენებული @@ -4677,7 +4688,7 @@ font-technology Variable ცვლადი Bicubic ბიკუბური Minimum მინიმალური Run Length Run Length - Roman Roman + Roman რომაული No conversion გარდაქმნის გარეშე Convert to destination სამიზნეში გარდაქმნა Convert to destination (preserve numbers) სამიზნეში გარდაქმნა (რიცხვების შენარჩუნებით) @@ -6159,7 +6170,7 @@ anchor Points: წერტილები: Close Path: ტრაექტორიის შეკვრა: Keep Handles სახელურების შენარჩუნება Straight სწორი - Stretch Curve მრუდის გაჭიმვა + Stretch Curve მრუდის გაწელვა @msg direct-select the anchor points to remove პირდაპირ მონიშნეთ მოსაცილებელი საყრდენი წერტილები @msg select segments, or paths, to add points to მონიშნეთ სეგმენტები ან ტრაექტორიები წერტილების დასამატებლად @msg select open paths to close მონიშნეთ შესაკრავი ღია ტრაექტორიები @@ -6304,7 +6315,7 @@ anchor Points: წერტილები: Join Point Text წერტილის ტექსტის შეერთება Remove Breaks წყვეტების წაშლა Split Text at Cursor ტექსტის დაყოფა კურსორთან - Set Alignment, Keep Position სწორების დაყენება მდებარეობის შენარჩუნებით + Set Alignment, Keep Position სწორების დაყენება პოზიციის შენარჩუნებით Reset Text Transform ტექსტის გარდაქმნის ჩამოყრა Replace Missing Fonts… დაკარგული შრიფტების ჩანაცვლება… Replace Missing Fonts დაკარგული შრიფტების ჩანაცვლება @@ -6315,7 +6326,7 @@ anchor Points: წერტილები: Break Text into Words ტექსტის დაყოფა სიტყვებად Break Text into Glyphs ტექსტის დაყოფა გლიფებად Reset Text Transform and Scale ტექსტის გარდაქმნისა და მასშტაბის ჩამოყრა - Set Alignment, Keep Position: სწორების დაყენება მდებარეობის შენარჩუნებით: + Set Alignment, Keep Position: სწორების დაყენება პოზიციის შენარჩუნებით: Break Text into: ტექსტის დაყოფა: Split at Cursor დაყოფა კურსორთან Reset Transform გარდაქმნის ჩამოყრა @@ -6324,7 +6335,7 @@ anchor Points: წერტილები: breakText Lines სტრიქონები breakText Words სიტყვები breakText Glyphs გლიფები -@msg Click in type with a Type tool to split it დასაყოფად დააწკაპუნეთ ტექსტში ტექსტის ხელსაწყოთი +@msg Click in type with a Type tool to split it დასაყოფად დაწკაპეთ ტექსტში ტექსტის ხელსაწყოთი @msg Too many type objects to change at once ერთდროულად შესაცვლელად ძალიან ბევრი ტექსტური ობიექტია @msg Too many pieces to make at once ერთდროულად შესაქმნელად ძალიან ბევრი ნაწილია @msg Select horizontal point or area type whose text fits აირჩიეთ ჰორიზონტალური წერტილის ან არის ტექსტი, რომლის ტექსტიც ეტევა @@ -6424,9 +6435,9 @@ breakText Glyphs გლიფები Fit to Corners and Ends კუთხეებსა და ბოლოებზე მორგება Remove Gaps ღრეჩოების მოცილება @msg that makes too many pieces (at most 100000): make the dashes longer ეს ძალიან ბევრ ნაწილს იძლევა (მაქსიმუმ 100000): გაზარდეთ ტირეების სიგრძე -@msg select paths, or segments, to divide აირჩიეთ დასაყოფი კონტურები ან სეგმენტები +@msg select paths, or segments, to divide აირჩიეთ დასაყოფი ტრაექტორიები ან სეგმენტები # Path Precision Tools add-on: Expand Dashes into Paths. - Expand Dashes into Paths ტირეების კონტურებად გაშლა + Expand Dashes into Paths ტირეების ტრაექტორიებად გაშლა @msg select paths with dashed strokes to expand აირჩიეთ გასაშლელი ტირეებიანი ხაზის კონტურები # Document Tools add-on (Object › Plug-ins › Document Tools): Save a Timestamped Copy. Document Tools დოკუმენტის ხელსაწყოები @@ -6526,6 +6537,7 @@ font-script Japanese იაპონური Japanese Double Postcard იაპონური ორმაგი ღია ბარათი Japanese Envelope #3 იაპონური კონვერტი #3 Japanese Envelope #4 იაპონური კონვერტი #4 +@msg image '{key}' has no pixels and was left out გამოსახულებას '{key}' პიქსელები არ აქვს და გამოტოვდა @msg not an RTF file: it doesn't start with {\\rtf RTF ფაილი არ არის: არ იწყება {\\rtf-ით # Type Tools add-on (Object › Plug-ins › Type Tools). Type Tools ტექსტის ხელსაწყოები @@ -6537,6 +6549,54 @@ font-script Japanese იაპონური Bow რკალი Spike პიკი Skip Spaces ჰარების გამოტოვება + +# The strings the report listed as missing for ka. + 1 Layer შრე: 1 + Art and Bright Colors მხატვრული და კაშკაშა ფერები + Automate ავტომატიზაცია + Color Table (4 colors) ფერების ცხრილი (ფერი: 4) + Current Colors (0) მიმდინარე ფერები (0) + Differences from VectorCraft Default: განსხვავებები VectorCraft-ის ნაგულისხმევთან: + Differences from [Default]: განსხვავებები [ნაგულისხმევი]-თან: + Dot Gain 15% Dot Gain 15% + Expand 1 Selected 1 მონიშნული ობიექტის გაშლა + Graphic Styles (4) გრაფიკული სტილები (4) + Gray Gamma 2.2 Gray Gamma 2.2 + Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono, Noto Sans SC (OFL). Icons: Lucide (ISC) + VectorCraft. ArtCraft-ის ნაწილი. MIT OR Apache-2.0. შრიფტები: Source Sans 3, Inter, JetBrains Mono, Noto Sans SC (OFL). ხატულები: Lucide (ISC) + VectorCraft. + Phone 375×812 ტელეფონი 375×812 + Photo Low Fidelity დაბალი სიზუსტის ფოტო + Print & Proof ბეჭდვა და კორექტურა + Printing & Proofing ბეჭდვა და კორექტურა + RECENT (1) ბოლოდროინდელი (1) + Reset Automation Automation-ის ჩამოყრა + Reset Essentials Classic Essentials Classic-ის ჩამოყრა + Reset Layout Layout-ის ჩამოყრა + Reset Painting Painting-ის ჩამოყრა + Reset Printing and Proofing Printing and Proofing-ის ჩამოყრა + Reset Tracing Tracing-ის ჩამოყრა + Reset Typography Typography-ის ჩამოყრა + Reset Web Web-ის ჩამოყრა + Restricted Colors შეზღუდული ფერები + Right-click an item to move it into this group. ამ ჯგუფში გადასატანად დაწკაპეთ ელემენტზე მარჯვენა ღილაკით. + Scale X მასშტაბი X + Scale X: მასშტაბი X: + Scale Y მასშტაბი Y + Scale Y: მასშტაბი Y: + Selected Styles Only (0) მხოლოდ მონიშნული სტილები (0) + Selected Swatches Only (0) მხოლოდ მონიშნული ფერის ნიმუშები (0) + Spot Colors (0) სპოტ-ფერები (0) + Tablet 1024×768 პლანშეტი 1024×768 + Video 1920×1080 ვიდეო 1920×1080 + Video 3840×2160 ვიდეო 3840×2160 + Web 1280×720 ვები 1280×720 + [High Resolution] [მაღალი გარჩევადობა] + [Low Resolution] [დაბალი გარჩევადობა] + [Medium Resolution] [საშუალო გარჩევადობა] + [PDF/X-1a:2001] [PDF/X-1a:2001] + [PDF/X-3:2002] [PDF/X-3:2002] + [PDF/X-4:2010] [PDF/X-4:2010] + [VectorCraft Default] [VectorCraft-ის ნაგულისხმევი] +@msg give corners of the path: anchors between two straight sides მიუთითეთ ტრაექტორიის კუთხეები: საყრდენი წერტილები ორ სწორ გვერდს შორის Release Type on a Path… ტექსტის ტრაექტორიიდან მოხსნა… Release Type on a Path ტექსტის ტრაექტორიიდან მოხსნა Text and Path ტექსტი და ტრაექტორია diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 2d67b2969..2a8648f0e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -499,6 +499,17 @@ digits Malayalam 말라얄람어 Inflate 부풀리기 Select objects, then pick an object type to give them a 3D effect. 개체를 선택한 다음 개체 유형을 골라 3D 효과를 적용하십시오. Extrude & Bevel… 돌출 및 경사… + 3D (Classic) 3D(클래식) + Extrude & Bevel (Classic)… 돌출 및 경사(클래식)… + Surface 표면 + Wireframe 와이어프레임 + No Shading 셰이딩 없음 + Plastic 플라스틱 + Highlight Intensity 하이라이트 강도 + Highlight Size 하이라이트 크기 + Blend Steps 혼합 단계 + Shading Color 셰이딩 색상 + Draw Hidden Faces 숨겨진 면 그리기 Revolve… 회전체… Inflate… 부풀리기… Materials… 재질… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 94ba3c954..b7e18b42c 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -447,6 +447,17 @@ Inflate Opblazen Select objects, then pick an object type to give them a 3D effect. Selecteer objecten en kies dan een objecttype om ze een 3D-effect te geven. Extrude & Bevel… Extruderen en afschuinen… + 3D (Classic) 3D (klassiek) + Extrude & Bevel (Classic)… Extruderen en afschuinen (klassiek)… + Surface Oppervlak + Wireframe Draadmodel + No Shading Geen arcering + Plastic Plastic + Highlight Intensity Glansintensiteit + Highlight Size Glansgrootte + Blend Steps Overvloeistappen + Shading Color Arceringskleur + Draw Hidden Faces Verborgen vlakken tekenen Revolve… Draaien… Inflate… Opblazen… Materials… Materialen… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 0b349dd26..f10fb0e84 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -434,6 +434,17 @@ Inflate Nadmuchanie Select objects, then pick an object type to give them a 3D effect. Zaznacz obiekty, a następnie wybierz typ obiektu, aby nadać im efekt 3D. Extrude & Bevel… Wyciągnięcie i fazowanie… + 3D (Classic) 3D (klasyczne) + Extrude & Bevel (Classic)… Wyciągnięcie i fazowanie (klasyczne)… + Surface Powierzchnia + Wireframe Szkielet + No Shading Bez cieniowania + Plastic Plastik + Highlight Intensity Intensywność połysku + Highlight Size Rozmiar połysku + Blend Steps Kroki przejścia + Shading Color Kolor cieniowania + Draw Hidden Faces Rysuj ukryte ściany Revolve… Obrót… Inflate… Nadmuchanie… Materials… Materiały… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 525fea8ae..9fe464533 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1029,6 +1029,17 @@ antiAlias Sharp Nítido Export: Exportar: Export… Exportar… Extrude & Bevel… Extrusão e Bisel… + 3D (Classic) 3D (Clássico) + Extrude & Bevel (Classic)… Extrusão e Bisel (Clássico)… + Surface Superfície + Wireframe Aramado + No Shading Sem Sombreamento + Plastic Plástico + Highlight Intensity Intensidade do Realce + Highlight Size Tamanho do Realce + Blend Steps Etapas de Mesclagem + Shading Color Cor do Sombreamento + Draw Hidden Faces Desenhar Faces Ocultas Eyedropper Conta-gotas Eyedropper Applies: O Conta-gotas Aplica: Eyedropper Options Opções do Conta-gotas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e4d95d7b4..9d3065882 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1134,6 +1134,17 @@ font-technology Variable Вариативные Exporting {} Экспорт {} Export… Экспорт… Extrude & Bevel… Выдавливание и фаска… + 3D (Classic) 3D (классические) + Extrude & Bevel (Classic)… Выдавливание и фаска (классическое)… + Surface Поверхность + Wireframe Каркас + No Shading Без затенения + Plastic Пластик + Highlight Intensity Интенсивность блика + Highlight Size Размер блика + Blend Steps Шаги перехода + Shading Color Цвет затенения + Draw Hidden Faces Рисовать скрытые грани Eyedropper Пипетка Sample Color from Screen Взять цвет с экрана This system has no screen color sampler В этой системе нет средства выбора цвета с экрана diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 5e86894b9..e592d5e70 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1115,6 +1115,17 @@ font-technology Variable Variabla Exporting {} Exporterar {} Export… Exportera… Extrude & Bevel… Extrudera och fasa… + 3D (Classic) 3D (klassisk) + Extrude & Bevel (Classic)… Extrudera och fasa (klassisk)… + Surface Yta + Wireframe Trådram + No Shading Ingen skuggning + Plastic Plast + Highlight Intensity Högdagerintensitet + Highlight Size Högdagerstorlek + Blend Steps Övergångssteg + Shading Color Skuggningsfärg + Draw Hidden Faces Rita dolda ytor Eyedropper Pipett Eyedropper Applies: Pipetten tillämpar: Eyedropper Options Pipettalternativ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 9f3d764e0..2605ade08 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1107,6 +1107,17 @@ font-technology Variable Değişken Exporting {} {} dışa aktarılıyor Export… İhracat… Extrude & Bevel… Ekstrüzyon ve Eğim… + 3D (Classic) 3D (Klasik) + Extrude & Bevel (Classic)… Ekstrüzyon ve Eğim (Klasik)… + Surface Yüzey + Wireframe Tel Kafes + No Shading Gölgeleme Yok + Plastic Plastik + Highlight Intensity Parlama Yoğunluğu + Highlight Size Parlama Boyutu + Blend Steps Karışım Adımları + Shading Color Gölgeleme Rengi + Draw Hidden Faces Gizli Yüzleri Çiz Eyedropper Damlalık Sample Color from Screen Ekrandan Örnek Renk This system has no screen color sampler Bu sistemin ekran rengi örnekleyicisi yok diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 1d3c6142a..ce1414475 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1131,6 +1131,17 @@ font-technology Variable Варіативні Exporting {} Експорт {} Export… Експорт… Extrude & Bevel… Видавлювання та фаска… + 3D (Classic) 3D (класичні) + Extrude & Bevel (Classic)… Видавлювання та фаска (класичне)… + Surface Поверхня + Wireframe Каркас + No Shading Без затінення + Plastic Пластик + Highlight Intensity Інтенсивність відблиску + Highlight Size Розмір відблиску + Blend Steps Кроки переходу + Shading Color Колір затінення + Draw Hidden Faces Малювати приховані грані Eyedropper Піпетка Sample Color from Screen Взяти колір з екрана This system has no screen color sampler У цій системі немає засобу вибору кольору з екрана diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 911843ab8..b0e4eb36a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1297,6 +1297,17 @@ font-technology Variable Biến thiên Exporting {} Đang xuất {} Export… Xuất… Extrude & Bevel… Kéo dài & vát… + 3D (Classic) 3D (cổ điển) + Extrude & Bevel (Classic)… Kéo dài & vát (cổ điển)… + Surface Bề mặt + Wireframe Khung dây + No Shading Không tô bóng + Plastic Nhựa + Highlight Intensity Cường độ điểm sáng + Highlight Size Kích thước điểm sáng + Blend Steps Số bước hòa trộn + Shading Color Màu tô bóng + Draw Hidden Faces Vẽ các mặt ẩn Eyedropper Ống hút màu Sample Color from Screen Lấy mẫu màu từ màn hình This system has no screen color sampler Hệ thống này không có công cụ lấy mẫu màu trên màn hình diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index dd5fd2b32..9e8e23252 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1077,6 +1077,17 @@ antiAlias Sharp 锐利 Exporting {} 正在导出 {} Export… 导出… Extrude & Bevel… 突出与斜角… + 3D (Classic) 3D (经典) + Extrude & Bevel (Classic)… 突出与斜角 (经典)… + Surface 表面 + Wireframe 线框 + No Shading 无底纹 + Plastic 塑料 + Highlight Intensity 高光强度 + Highlight Size 高光大小 + Blend Steps 混合步骤 + Shading Color 底纹颜色 + Draw Hidden Faces 绘制隐藏表面 Eyedropper 吸管 Sample Color from Screen 从屏幕取样颜色 This system has no screen color sampler 此系统没有屏幕取色工具 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 29b82cd40..e02b9e21c 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1101,6 +1101,17 @@ font-technology Variable 可變 Exporting {} 正在匯出 {} Export… 匯出… Extrude & Bevel… 突出與斜角… + 3D (Classic) 3D (經典) + Extrude & Bevel (Classic)… 突出與斜角 (經典)… + Surface 表面 + Wireframe 線框 + No Shading 無網底 + Plastic 塑膠 + Highlight Intensity 亮部強度 + Highlight Size 亮部大小 + Blend Steps 漸變階數 + Shading Color 網底顏色 + Draw Hidden Faces 繪製隱藏表面 Eyedropper 滴管 Sample Color from Screen 從螢幕取樣顏色 This system has no screen color sampler 此系統沒有螢幕取色工具 diff --git a/crates/ui-egui/src/menus/submenus.rs b/crates/ui-egui/src/menus/submenus.rs index 21985a117..9e8a7ac65 100644 --- a/crates/ui-egui/src/menus/submenus.rs +++ b/crates/ui-egui/src/menus/submenus.rs @@ -108,6 +108,9 @@ pub(super) fn font_items(english_names: bool) -> Vec { items.3.clone() } +/// The catalogue's menu path of the effects in 3D and Materials › 3D (Classic). +const CLASSIC_3D: &[&str] = &["Effect", "3D and Materials", "3D (Classic)"]; + /// The raster effects' submenus of the Effect menu (the Photoshop-style effects, below the /// vector effects): (submenu, the catalogue's menu path of its effects). /// The raster Stylize submenu shares its path with the vector one and lists only the effects at @@ -194,6 +197,10 @@ pub(super) fn effect_menu() -> Vec { _ => 9, }); items.push(todo("Materials…")); + let classic: Vec = cat.iter().filter(|e| e.menu == CLASSIC_3D).map(item).collect(); + if !classic.is_empty() { + items.push(sub("3D (Classic)", classic)); + } } out.push(sub(sub_name, items)); } @@ -214,7 +221,9 @@ pub(super) fn effect_menu() -> Vec { out.extend(raster); } // Anything not placed above (future effects) still shows up. - let placed = |e: &vectorcraft_effects::EffectInfo| top_level(e) || in_vector_menu(e) || RASTER_MENUS.iter().any(|(_, path)| e.menu == *path); + let placed = |e: &vectorcraft_effects::EffectInfo| { + top_level(e) || in_vector_menu(e) || e.menu == CLASSIC_3D || RASTER_MENUS.iter().any(|(_, path)| e.menu == *path) + }; for e in cat.iter().filter(|e| !placed(e)) { out.push(Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id }))); } diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 3f082ac6d..a0e493561 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -377,6 +377,22 @@ fn saved_views_store_and_restore_the_view() { assert!(!menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("view.goto2"))); } +/// Effect › 3D and Materials › 3D (Classic) › Extrude & Bevel (Classic)… opens its effect dialog, +/// after Materials…, and nowhere else. +#[test] +fn classic_extrude_bevel_is_in_the_3d_classic_submenu() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let entries = menu_entries(&app); + let classic: Vec<_> = entries.iter().filter(|e| e.params.get("effect").and_then(Value::as_str) == Some("threeD.classicExtrude")).collect(); + assert_eq!(classic.len(), 1, "listed once"); + let e = classic[0]; + assert_eq!(e.path, ["Effect", "3D and Materials", "3D (Classic)"]); + assert_eq!((e.label.as_str(), e.command.as_deref()), ("Extrude & Bevel (Classic)…", Some("effect.dialog"))); + let at = |label: &str| entries.iter().position(|x| x.label == label && x.path.get(1).is_some_and(|p| p == "3D and Materials")); + assert!(at("Materials…").unwrap() < at("Extrude & Bevel (Classic)…").unwrap()); +} + /// A document's 25 views are all listed at the bottom of the View menu, by name, and the last /// one brings back its zoom, centre and rotation after the view changed. #[test] diff --git a/crates/ui-egui/src/tests/plugins.rs b/crates/ui-egui/src/tests/plugins.rs index ea2124487..4bda0c94e 100644 --- a/crates/ui-egui/src/tests/plugins.rs +++ b/crates/ui-egui/src/tests/plugins.rs @@ -130,8 +130,9 @@ fn asking(id: &str, name: &str, permissions: &str) -> Vec { /// Installing a plug-in that lists permissions asks first, showing its name, id, version, file hash /// and each permission in plain words, with hidden characters from the manifest escaped. Cancel -/// installs nothing, OK installs it; another module with its id and the same permissions asks only -/// whether to take the installed one's place, and one that asks for another permission asks again. +/// installs nothing, OK installs it; installing the same module again doesn't ask, one with other +/// bytes asks again, even when it lists the same permissions, and then whether to take the +/// installed one's place, as does one that asks for another permission. #[test] fn installing_a_plugin_that_lists_permissions_asks_first() { let mut app = app(); @@ -165,11 +166,22 @@ fn installing_a_plugin_that_lists_permissions_asks_first() { assert!(app.ui.dialog.is_none()); assert!(vectorcraft_plugins::registry::get(id).is_some()); assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(hash)); - // Another module with the same permissions asks for none of them again, but asks before it - // takes the installed one's place, showing its id and file hash. + // The same module again: installed without asking. + app.run("ui.installPlugin", json!({"dataBase64": data})).unwrap(); + assert!(app.ui.dialog.is_none(), "the same bytes don't ask again"); + // Other bytes with the same permissions ask for them, from both ways in; Cancel keeps the one + // installed. let again = asking(id, "Tidy 2", r#"["doc.write","doc.read"]"#); let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&again)})).unwrap(); - assert_eq!(r["pending"], dialogs::confirm::KIND); + assert_eq!(r["pending"], dialogs::confirm::KIND, "other bytes ask again"); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("message"), "Install “Tidy 2”, which asks for these permissions?"); + dialogs::cancel(&mut app); + assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some(name.into()), "Cancel keeps the one installed"); + io::open_bytes(&mut app, "tidy2.wasm", &again, None).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::KIND), "File › Open asks too"); + dialogs::confirm(&mut app).unwrap(); + // OK on the permissions goes on to ask before it takes the installed one's place, showing its + // id and file hash; Cancel there keeps the one installed and records no grant. let d = app.ui.dialog.as_ref().unwrap(); assert_eq!(d.str("message"), "Install “Tidy 2” in place of the plug-in with the same ID?"); let again_hash = vectorcraft_plugins::permissions::sha256_hex(&again); @@ -181,10 +193,14 @@ fn installing_a_plugin_that_lists_permissions_asks_first() { ); dialogs::cancel(&mut app); assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some(name.into()), "Cancel keeps the one installed"); + assert_ne!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(again_hash), "Cancel records no grant"); app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&again)})).unwrap(); dialogs::confirm(&mut app).unwrap(); - assert!(app.ui.dialog.is_none(), "the same permissions don't ask again"); + dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "both questions are answered"); assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into())); + let new_hash = vectorcraft_plugins::permissions::sha256_hex(&again); + assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(new_hash), "OK records the new module's hash"); // One that asks for another permission asks again. let more = asking(id, "Tidy 3", r#"["doc.read","doc.write","state"]"#); let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&more)})).unwrap(); diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 37a504367..8f90ce5f4 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -13,7 +13,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md]( |---|---:|---:|---:|---:| | Illustrator effects (vector and stylize) | 50 | 50 | 100% | — | | Photoshop effects (raster, Effect Gallery) | 57 | 57 | 100% | 1–2 (Effect Gallery thumbnails and in-dialog preview) | -| 3D and Materials | 4 (+3 Classic) | 4 (initial: Extrude & Bevel, Revolve, Inflate, Rotate) | ~25% | 25–45 | +| 3D and Materials | 4 (+3 Classic) | 4 (+1 Classic; initial: Extrude & Bevel, Revolve, Inflate, Rotate, Extrude & Bevel (Classic)) | ~25% | 25–45 | | Beyond Illustrator: Effect › Color Adjustments (6), effect plug-ins (WebAssembly) | — | 6 + plug-ins | — | — | ## Illustrator effects @@ -181,13 +181,13 @@ VectorCraft choices; an original approximation, never compared with another appl | Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | | Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | | Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; no materials, shadows or ray tracing | -| 3D (Classic): Extrude & Bevel, Revolve, Rotate | none | +| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic), Rotate (Classic) and Map Art: none | | Turntable (30.0 beta) | none | | Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object collects an object with Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, and Export writes its solids as a USDA (the default), USDZ, glTF or OBJ mesh with a material in each solid's paint color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`); no textures, map art or shading ([3D export help](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)) | The `three-d` crate (depends on `geom` and `pathops`, with no UI or GPU dependencies) builds, rotates, projects, shades and depth-sorts the surfaces; the effects crate turns them into art. What's left (materials and shadows, ray-traced rendering, -angular cut caps, exact visibility, 3D Classic, Turntable) is 25–45 h. +angular cut caps, exact visibility, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable) is 25–45 h. ## Revision history diff --git a/docs/gaps.md b/docs/gaps.md index 057c141f6..48897777f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -641,8 +641,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done:** an initial live Revolve in the `three-d` crate (#846, from the contributor who offered in #605), with automatic smooth vector shading, default-on circular caps and spike-free continuous rims; Extrude & Bevel, Inflate and Rotate with Position presets ([three-d.md](three-d.md), #1407). Colored Inflate has automatic smooth vector lighting from shared membrane normals. White-light Inflate keeps its existing appearance. All four take one to ten colored directional lights, with neutral ambient applied once, a light list and independent previewing controls. Standard, Diffuse, Top Left and Right select original rigs, shared with commands; manual settings show Custom. - **Missing:** materials (base, Substance, graphics), shadows, - ray-traced rendering, angular cut caps, exact visibility for intersecting and concave surfaces, 3D Classic, - Turntable, textures, map art and shading in 3D export. + ray-traced rendering, angular cut caps, exact visibility for intersecting and concave surfaces, the rest of 3D + Classic (Revolve, Rotate, Map Art), Turntable, textures, map art and shading in 3D export. - **Impact:** specialist; Materials is the one menu stub left. - **Estimate:** 25–45 h; plan in `plan/` before coding. diff --git a/docs/mcp.md b/docs/mcp.md index f19094ef2..81c6b38e0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2348,8 +2348,9 @@ See [Revolve](revolve.md) for the scope and limitations. `effect.lightingPreset {index: int, preset: "standard" | "diffuse" | "topLeft" | "right", item?, ids?, target?}` replaces the lighting rig and ambient of an existing Revolve, Extrude & Bevel, Inflate or Rotate effect. `item: null` addresses object-level effects; an item index addresses a fill/stroke's -effects. All targets must have a 3D effect at `index`, otherwise nothing changes. The operation -is one undo step and preserves Shading, geometry, source paint and the view. +effects. All targets must have a 3D effect at `index`, otherwise nothing changes. Extrude & Bevel +(Classic) has no lighting presets: a target with it at `index` is an error, nothing changes, and +`effect.setParams` sets its lights. The operation is one undo step and preserves Shading, geometry, source paint and the view. The four original rigs use one white front light at 80% intensity. Standard keeps the existing default; Diffuse uses 100% Softness and 35% Ambient Light, Top Left uses Rotation 135 and Height @@ -2391,6 +2392,33 @@ All three take `rotationX/Y/Z` (degrees, -180..180, default the Off-Axis Front p `perspectiveAngle` and the lighting parameters as Revolve. See [three-d.md](three-d.md) for the Position presets, the scope and what differs from the public documentation. +## Extrude & Bevel (Classic) + +`effect.apply {effect: "threeD.classicExtrude", params: {...}, ids?, item?}` is Effect › 3D and Materials › +3D (Classic) › Extrude & Bevel (Classic)…. It builds the same solid as `threeD.extrude` and draws its faces with the +classic surfaces. Parameters: `depth` (points, 0..2000, default 50), `cap` (true solid, false hollow), `bevel` (`none`, +`classic`, `rounded`, `cove`, `step`, `ogee`), `bevelHeight` (points, 1..100, default 4; the bevel is as wide as it +is tall), `bevelInside` (false adds the bevel around the shape, true carves it into the shape), `rotationX/Y/Z` and +`perspectiveAngle` as above, and the surface: + +- `surface`: `wireframe` (the edges of every face, hidden faces included, with no fill), `none` (the art's own paint, + unlit), `diffuse` (soft light) or `plastic` (soft light plus a highlight; the default). +- `lightRotation`, `lightHeight`, `lightBehind` and `lightIntensity` (0..100%, default 100) place one light, or + `lights` holds one to ten (`intensity`, `rotation`, `height`, `behind`; their light adds up, and `color` and + `softness` are not used). `ambient` (0..100%, default 50) lights every face. +- `highlightIntensity` (0..100%, default 60) and `highlightSize` (0..100%, default 90; 100 is broad) shape the Plastic + highlight. `blendSteps` (whole number, 1..256, default 25) is how many levels the shading is rounded to. +- `shadingColor`: `black` (the default), `custom` (darkens toward `shadingCustom`, a `#rrggbb` color) or `none` (the + paint is kept and only the highlight lightens it). `drawHiddenFaces` also draws the faces turned away from the + viewer, behind the others. + +The defaults are VectorCraft's own. An unknown parameter, a number out of range, a flag that is not true or false, or +an unknown surface or shading color is refused with an error and the document is not changed. + +```json +{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"threeD.classicExtrude","item":null,"params":{"depth":40,"bevel":"rounded","bevelHeight":6,"surface":"diffuse","blendSteps":64}}}} +``` + ## Edit Colors and Recolor Artwork @@ -6576,10 +6604,10 @@ runs it where it is called, so the call may take minutes; `notifications/cancell A plug-in whose manifest lists permissions (`permissions` in its `plugin.inspect` entry, with `needsApproval: true`) installs only with `grant: [permission]` listing every one of them: show the user the list and ask first, and pass the entry's `hash` as `hash` so that a module that changed since is refused. Without `grant`, `plugin.install` returns -an error naming the permissions needed. The grant is kept with the module's hash; without a `Plug-ins` folder, a -module with the same id that lists no new permission installs without `grant`. A grant kept in a `Plug-ins` folder -covers only the module with that hash, so another module under the id needs `grant` again. `plugin.remove` forgets the -grant. A +an error naming the permissions needed. The grant is kept with the module's hash and covers only that module: the +same bytes install again without `grant`, and a module with other bytes has `needsApproval: true` and needs a new +`grant` even when it lists the same permissions. `plugin.install` does not check that a person approved `grant`: it +trusts the client that sends it. `plugin.remove` forgets the grant. A filter without `doc.write` or `state` that returns output needing them gets an error and the document is unchanged. A plug-in of API level 2 (`api: 2` in its entry) that lists `progress` may report its progress, see Cancel and write log lines: `notifications/cancelled` makes its `vc_cancelled` return 1, and its log lines go to the server's log diff --git a/docs/plugins.md b/docs/plugins.md index 14c570e7e..5aaedd2eb 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -26,7 +26,7 @@ This page is the contract for plug-in authors and the reference for the host (`c | `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, commands?, size, source, api, permissions, hash, cacheBytes}], "folder": report}` (`api` is the manifest's [plug-in API level](#permissions); `budget: "heavy"` for a [heavy filter](#heavy-filters); `commands` lists its [commands](#commands); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256; `cacheBytes` is the size of its [cache](#settings-and-cache)) | | `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). | | `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`; an `ids` that is not a list of object ids is an error). One undo step; the result is selected, except the objects it locked or made guides. | -| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace"?: bool`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A module with other bytes than the plug-in installed with its id, or, with none installed, one whose id has [settings and a cache](#settings-and-cache) in the `Plug-ins` folder, installs only with `replace: true`, once the user agreed; it takes that plug-in's place and gets its settings and cache. The same module installs again without `replace`, and `replace: false` refuses a module whose id is installed. A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed. With `hash`, a module whose SHA-256 differs is refused. | +| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace"?: bool`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A module with other bytes than the plug-in installed with its id, or, with none installed, one whose id has [settings and a cache](#settings-and-cache) in the `Plug-ins` folder, installs only with `replace: true`, once the user agreed; it takes that plug-in's place and gets its settings and cache. The same module installs again without `replace`, and `replace: false` refuses a module whose id is installed. A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed, unless the user already granted a module with the same SHA-256. With `hash`, a module whose SHA-256 differs is refused. | | `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have, plus `needsApproval` (true when `plugin.install` needs `grant`) and `needsReplace` (true when `plugin.install` needs `replace: true`). | | `plugin.remove` | `{"id"}` | Uninstalls, and deletes it from the `Plug-ins` folder with its [settings and cache](#settings-and-cache). | | `plugin.clearCache` | `{"id"?}` | Deletes every entry in the [cache](#settings-and-cache) of plug-in `id`, or of every installed plug-in; settings stay. Returns `{"cleared": [id], "bytesFreed"}`. | @@ -164,7 +164,8 @@ capability model proposed for PhotoCraft plug-ins names shown have control and bidirectional characters escaped (`\u{202E}`) and are cut to 64 characters. `plugin.install` refuses the module, naming the permissions it needs, unless `grant` lists them all, which an agent passes once the user agreed (`plugin.inspect`'s `needsApproval` tells it beforehand), with `hash` set to the - `plugin.inspect` entry's `hash` so that the module installed is the one the user saw. + `plugin.inspect` entry's `hash` so that the module installed is the one the user saw. `plugin.install` does not + check that a person approved `grant`: it trusts the client that sends it. - **Replacing asks first.** When the file is another module than the plug-in installed with its id, or, with none installed, its id has settings and a cache in the `Plug-ins` folder, Install Plug-in… and File › Open show its id and SHA-256 and ask whether to install it in place of that plug-in, which gives it the settings and cache kept for @@ -172,16 +173,21 @@ capability model proposed for PhotoCraft plug-ins the user agreed (`plugin.inspect`'s `needsReplace` tells it beforehand). The Additional Plug-ins Folder and `plugin.reload` do not ask. - The grant is recorded with the plug-in's id, the module's hash and the permissions, in `grants.json` in the - `Plug-ins` folder. Without a `Plug-ins` folder, installing a module with the same id that lists no new permission - needs no new approval; one that lists more asks again. A grant kept in a `Plug-ins` folder covers only the module - with the recorded hash: another module under the id needs `grant` again, even when it lists the same permissions, - and does not get the id's settings and cache until then. `plugin.remove` forgets the grant. An entry of `grants.json` that this version can't use (a + `Plug-ins` folder, and covers only that module. Installing the same bytes again needs no new approval. A module with + other bytes asks again, even when it lists the same permissions, and does not get the id's settings and cache until + it is approved. Until then, the installed module and its grant stay as they are; once it is approved, the grant + records the new hash. When the new module or its grant can't be written to the `Plug-ins` folder, the folder keeps + the old module and its grant, and the old module loads at the next start. If the write that puts the old module back + fails as well, the folder holds the new module with the old grant, the grant does not cover that module, and the + plug-in is skipped at the next start until it is approved again. A grant whose hash is not 64 lower-case + hexadecimal digits covers nothing. `plugin.remove` forgets the grant. An entry of `grants.json` that this version can't use (a permission it doesn't know, an invalid id) is skipped and stays in the file until its plug-in is granted again or removed. A `grants.json` that is not a JSON list is never overwritten, and the file is never written larger than 1 MiB, the most that is read. When a `Plug-ins` folder is in use, only the grants kept in it count for `plugin.install`, `plugin.inspect`, `plugin.reload` and the Additional Plug-ins Folder. At startup, - a stored plug-in whose permissions are not granted to that module is skipped. The Additional Plug-ins Folder and `plugin.reload` - install only plug-ins whose permissions were granted, and report the others. + a stored plug-in is skipped when no grant covers its bytes and the permissions it lists. The Additional Plug-ins + Folder and `plugin.reload` install only plug-ins that such a grant covers, and report the others. A module without a + `permissions` list, or with an empty one, needs no grant. - The host enforces the grant on a filter run and a command run: without `doc.read` the filter is given no objects; without `state` its `documentData` is `null` and an output that sets it is refused; without `doc.write` an output that changes the document is refused. A refused output leaves the document as it was. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8761e77e9..f80520e49 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -201,7 +201,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors | 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (four generated libraries, User Defined, Other Library…, Save Brushes); remaining: Art brush Overlap, Pattern brush auto corners, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, 3D Classic, Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, on-canvas Direct Selection of parts inside dynamic symbol instances and their `.ai` round trip, more built-in symbol libraries, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic symbols keep per-instance appearance linked, #4938) are done | 4–8 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged | @@ -209,7 +209,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, on-canvas Direct Selection of parts inside dynamic symbol instances and their `.ai` round trip, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic symbols keep per-instance appearance linked, #4938) are done | 4–8 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, 3D Classic, Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 52% | the Effect Gallery's filter thumbnails and in-dialog preview area ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)) | 1–2 | estimated (raised from 49%: the Effect Gallery shipped, #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `dialogs::effect_gallery::tests::stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; depth estimated | @@ -474,10 +474,10 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Strokes, brushes, width profiles | 5 | 90% | the Art brush's Overlap, auto-generated Pattern brush corners and new tile art in Pattern Brush Options (Brush Options for every brush type done), more built-in brush libraries (four generated in code are done), pen pressure on macOS (the windowing layer reports none there), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 8–15 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | -| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, 3D Classic and more complete visibility remain | 25–45 | +| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | | Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize) and Texturizer's Load Texture (since done, see the area table above, as is the Effect Gallery, #2883); Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur (Normal, Edge Only and Overlay Edge), Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 8–14 | -| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, 3D Classic and more complete visibility remain | 25–45 | +| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | | Type advanced | 4 | 50% | CJK composition for vertical type (mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko is done, its up/down settings remain; No Break is done), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 | | Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths, System Layout, styled Type-range canvas Paste (#5636), and pattern hyphenation for eleven Indian languages with grapheme/cluster protection (#4947) are done; Sanskrit automatic patterns remain pending an allowlisted source | 14–19 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | more built-in symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | @@ -558,6 +558,7 @@ since 2026-10-01. - **Warp Options (#1760):** Effect > Warp and existing Appearance effect links share the fifteen existing styles, original schematic previews, orientation choices and percent Bend/Distortion controls. Focused tests passed for real numeric entry and slider input, Preview/Cancel, immediate Enter/OK, mixed-style edits, target/slot/visibility preservation and Undo/Redo. Each applied style stays unchanged until an explicit Style choice. Original style-menu and live-preview captures were reviewed; their numeric values were supplied programmatically. Public references: [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) and [Using effects](https://helpx.adobe.com/illustrator/using/effects.html). Warp geometry, optional Properties integration and remembered defaults are unchanged; no broader parity claim. - **Placed gradients follow the Warp effects:** under each of the fifteen Warp styles with a bend or a distortion, a gradient placed with the Gradient tool moves and scales from the object's bounds before the warp to its bounds after it, as under Free Distort; a stroke's gradient uses those bounds inflated by the stroke. This holds for an object's own Warp and for a fill's or stroke's own, on the canvas and in Object › Expand of the fill. In a group with a Warp effect, each path's and compound path's gradients follow its own bounds, a nested group's own fills and strokes follow the nested group's bounds, and the group's own fills and strokes follow the group's bounds. In the art Pathfinder and compound shapes take, a symbol instance's Warp moves the gradients of its symbol's art by the art's bounds, and a compound shape's Warp moves its gradients by the bounds of its area. A gradient fitted to the object still fits the warped outline, and a warp with no bend and no distortion leaves the gradient exactly in place. Existing documents with a Warp effect on art with a placed gradient draw differently: before, the gradient stayed where it was while the outline changed. How gradients follow the warp is VectorCraft's own rule; what the Warp effects do to the object is described in [Reshaping with effects](https://helpx.adobe.com/illustrator/using/reshaping-objects-effects.html) (effects `tests::warp_bends_when_the_warp_moves_the_outline`, `bake::tests::baked_warps_move_placed_gradients_from_the_bounds_to_the_warped_bounds`, `bake::tests::a_warp_with_no_bend_leaves_a_placed_gradient_exactly_in_place`, `bake::tests::a_fills_and_a_strokes_own_warp_move_its_placed_gradient_with_the_bounds`, `group::tests::a_groups_warp_moves_the_gradients_placed_on_its_paths_by_each_paths_bounds`, `group::tests::a_groups_warp_moves_its_own_a_nested_groups_and_a_compound_paths_gradients_by_their_bounds`, `group::tests::shape_art_moves_the_gradients_of_a_symbol_instances_art_and_a_compound_shape_with_a_warp`; render `objectfx::a_paths_gradient_moves_and_scales_with_its_warped_bounds`; engine `objexpand::a_placed_gradient_moved_by_a_warp_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. +- **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. @@ -1563,6 +1564,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (a CMYK image's through the working CMYK profile; never a linked file's), and symbol instances with their symbol and transform. Every object counts toward the input's half of the memory cap; one whose fields besides its pixels and runs don't fit fails the run naming it and its size, a path or compound path that doesn't fit fails it too, and so do more than 100,000 of these objects. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before, except that compound paths that clip a clip group and paths inside a group that clips one are left out for every filter. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. +- **Plug-in grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module with other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin..` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Plug-in settings and cache (G17, #2191):** at plug-in API level 2 a plug-in with the `settings` permission keeps settings of its own (`env.vc_settings_get`, `_set`, `_delete`) and one with `cache` a cache the user can clear (`env.vc_cache_read`, `_write`, `_clear`), under its id in the `Plug-ins` folder. Keys are plain names, never paths; VectorCraft's own caps refuse a write past 256 settings, 1 MiB of settings or 512 MiB of cache; a damaged settings file is moved aside and the settings start empty. `plugin.clearCache` empties a cache, `plugin.list` and `plugin.info` give `cacheBytes`, replacing a plug-in keeps its storage and `plugin.remove` deletes it. Another module under an id that is installed or has storage installs only with `replace: true`, which the app asks the user for; each storage call counts the whole settings file or cache entry it reads and writes toward the run's 1 GiB. Regressions: `crates/plugins/src/storage.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_clear_cache.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. diff --git a/docs/three-d.md b/docs/three-d.md index 92758f6b7..f3088cc8d 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -239,8 +239,8 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. counted on its own), Rotate (which only turns the outline) 65,536. The limit is counted on the shape as drawn and, when its contours meet, again on the merged outline, which can have more points (bars crossing in a grid trace every hole between them); past it, the shape is too complex for the effect. -- **3D (Classic)** (the track cube, surface types, blend steps, map art), Turntable and 3D export are separate, - unbuilt items. +- **3D (Classic)**: Extrude & Bevel (Classic) is built, with its surface types and blend steps. Revolve (Classic), + Rotate (Classic), the track cube, map art, Turntable and 3D export are separate, unbuilt items. Release measurements and a repeatable CLI scene are in [3D lighting performance](3d-lighting-performance.md).