diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 1c1ca96d9f..8a4f49ecab 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -210,22 +210,22 @@ pub fn effect_catalog() -> Vec { 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}), + "{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), preserveSpotColors: bool (false; a color linked to a spot or global swatch stays a tint of that swatch, lighter where the highlight is, with black multiplied and overprinting where the faces darken, so a spot color keeps its plate; it does nothing while shadingColor is custom), 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","preserveSpotColors":false,"drawHiddenFaces":false}), ), g( CLASSIC_REVOLVE, "Revolve (Classic)…", &["Effect", "3D and Materials", "3D (Classic)"], - "{angle: degrees (0..360, 360; how far the profile sweeps round the axis), cap: bool (true: solid, the open ends closed; false: hollow), offset: pt (0..1000, 0; the axis's distance from the profile, a ring past 0), edge: left|right (left; the axis is at the left or the right edge of the profile's bounds), 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 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)}. The profile sweeps round a vertical axis, as Revolve's does, painted with the top fill (else the top stroke); every face is drawn, back to front. The defaults are VectorCraft's own.", - json!({"angle":360.0,"cap":true,"offset":0.0,"edge":"left","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"}), + "{angle: degrees (0..360, 360; how far the profile sweeps round the axis), cap: bool (true: solid, the open ends closed; false: hollow), offset: pt (0..1000, 0; the axis's distance from the profile, a ring past 0), edge: left|right (left; the axis is at the left or the right edge of the profile's bounds), 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 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), preserveSpotColors: bool (false; a color linked to a spot or global swatch stays a tint of that swatch, lighter where the highlight is, with black multiplied and overprinting where the faces darken, so a spot color keeps its plate; it does nothing while shadingColor is custom)}. The profile sweeps round a vertical axis, as Revolve's does, painted with the top fill (else the top stroke); every face is drawn, back to front. The defaults are VectorCraft's own.", + json!({"angle":360.0,"cap":true,"offset":0.0,"edge":"left","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","preserveSpotColors":false}), ), g( CLASSIC_ROTATE, "Rotate (Classic)…", &["Effect", "3D and Materials", "3D (Classic)"], - "{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: none|diffuse (diffuse; none keeps the paint unlit; diffuse lights the art softly), 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, the art whichever way it faces), blendSteps: integer (1..256, 25; how many levels the shading is rounded to), shadingColor: black|custom|none (black; the color the art darkens toward; none keeps the paint), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward)}. Every fill and stroke turns as one flat plane about the center of the object's bounds, as Rotate's do, each in its own paint, and is seen from either side. The defaults are VectorCraft's own.", - json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"diffuse","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000"}), + "{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: none|diffuse (diffuse; none keeps the paint unlit; diffuse lights the art softly), 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, the art whichever way it faces), blendSteps: integer (1..256, 25; how many levels the shading is rounded to), shadingColor: black|custom|none (black; the color the art darkens toward; none keeps the paint), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), preserveSpotColors: bool (false; a color linked to a spot or global swatch stays a tint of that swatch, with black multiplied and overprinting where the art darkens, so a spot color keeps its plate; it does nothing while shadingColor is custom)}. Every fill and stroke turns as one flat plane about the center of the object's bounds, as Rotate's do, each in its own paint, and is seen from either side. The defaults are VectorCraft's own.", + json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"diffuse","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","preserveSpotColors":false}), ), g( "convertToShape.rectangle", diff --git a/crates/effects/src/revolve/classic.rs b/crates/effects/src/revolve/classic.rs index 1881381567..8dc013e9dc 100644 --- a/crates/effects/src/revolve/classic.rs +++ b/crates/effects/src/revolve/classic.rs @@ -23,7 +23,7 @@ const RANGES: [(&str, f64, f64, bool); 13] = [ ("blendSteps", 1.0, 256.0, true), ]; -const FLAGS: [&str; 2] = ["cap", "lightBehind"]; +const FLAGS: [&str; 3] = ["cap", "lightBehind", "preserveSpotColors"]; /// The surface Revolve (Classic)'s parameters `p` sweep: Revolve's angle, cap, offset, edge, /// rotation and perspective, built unshaded, since the classic surfaces light its faces. @@ -118,6 +118,52 @@ mod tests { } } + /// With Preserve Spot Colors, a fill linked to a spot swatch stays linked on every face: the + /// highlight lowers its tint, and the shadow is black multiplied and overprinting over it. + #[test] + fn preserve_spot_colors_keeps_the_swatch_on_every_face() { + let ink = Color::cmyk(0.0, 1.0, 0.0, 0.2); + let spot = |params: Value| { + let mut n = profile(); + *n.appearance = Appearance::basic(Paint::Solid { color: ink, swatch: Some("Ink".into()), tint: 0.5 }, Paint::None, 0.0); + n.appearance.effects.push(crate::new_effect(CLASSIC_REVOLVE, ¶ms).unwrap()); + crate::revolve_art(&n).unwrap() + }; + let art = spot(json!({"preserveSpotColors": true})); + let mut darkened = 0; + for face in faces(&art) { + let mut fills = face.appearance.items.iter().filter_map(|i| match i { + AppearanceItem::Fill(f) => Some(f), + AppearanceItem::Stroke(_) => None, + }); + match fills.next().map(|f| &f.paint) { + Some(Paint::Solid { color, swatch: Some(name), tint }) => { + assert_eq!(name, "Ink"); + assert!(*tint > 0.0 && *tint <= 0.5, "{tint}"); + assert_eq!(*color, ink.tinted(*tint / 0.5), "the color is the swatch at the face's tint"); + } + other => panic!("the face keeps its swatch: {other:?}"), + } + if let Some(shadow) = fills.next() { + assert!(matches!(shadow.paint.color(), Some(Color::Gray { .. })), "{:?}", shadow.paint); + assert_eq!((shadow.blend, shadow.overprint), (vectorcraft_doc::color::BlendMode::Multiply, true)); + darkened += 1; + } + } + assert!(darkened > 0, "faces turned from the light are darkened"); + // Off, and with a custom shading color, the faces are process mixes again. + for params in [json!({}), json!({"preserveSpotColors": true, "shadingColor": "custom", "shadingCustom": "#336699"})] { + let art = spot(params.clone()); + let linked = faces(&art) + .iter() + .filter(|f| { + f.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if matches!(f.paint, Paint::Solid { swatch: Some(_), .. }))) + }) + .count(); + assert!(linked < faces(&art).len(), "{params}: shaded faces lose the swatch"); + } + } + #[test] fn angle_offset_edge_and_cap_shape_the_surface() { let full = revolved(json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0})).visual_bounds().unwrap(); @@ -207,6 +253,7 @@ mod tests { (json!({"rotationY": -181}), "`rotationY` must be between -180 and 180"), (json!({"blendSteps": 2.5}), "`blendSteps` must be a whole number"), (json!({"lightBehind": 1}), "`lightBehind` must be true or false"), + (json!({"preserveSpotColors": "on"}), "`preserveSpotColors` must be true or false"), (json!({"surface": "glossy"}), "`surface` must be one of"), (json!({"shadingColor": "white"}), "`shadingColor` must be one of"), (json!({"shadingCustom": "red"}), "`shadingCustom` must be a #rrggbb color"), diff --git a/crates/effects/src/solids/classic.rs b/crates/effects/src/solids/classic.rs index 29c05406a2..f48b94bce9 100644 --- a/crates/effects/src/solids/classic.rs +++ b/crates/effects/src/solids/classic.rs @@ -102,7 +102,7 @@ const RANGES: [(&str, f64, f64, bool); 13] = [ ("blendSteps", 1.0, 256.0, true), ]; -const FLAGS: [&str; 4] = ["cap", "bevelInside", "lightBehind", "drawHiddenFaces"]; +const FLAGS: [&str; 5] = ["cap", "bevelInside", "lightBehind", "drawHiddenFaces", "preserveSpotColors"]; /// 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 @@ -127,7 +127,7 @@ const ROTATE_RANGES: [(&str, f64, f64, bool); 9] = [ /// Check Rotate (Classic)'s parameters `p` (its defaults filled in) as [`validate`] checks Extrude & /// Bevel (Classic)'s, with `surface` No Shading or Diffuse only. pub fn validate_rotate(p: &Value) -> Result<(), String> { - validate_classic(CLASSIC_ROTATE, p, &ROTATE_RANGES, &["lightBehind"])?; + validate_classic(CLASSIC_ROTATE, p, &ROTATE_RANGES, &["lightBehind", "preserveSpotColors"])?; if p.get("surface").is_some_and(|v| !v.as_str().is_some_and(|s| ROTATE_SURFACES.contains(&s))) { return Err(format!("`surface` must be one of {}", ROTATE_SURFACES.join(", "))); } @@ -192,6 +192,35 @@ fn shaded(c: Color, tone: f64, spec: f64, shade: Option) -> Color { Color::rgb(r, g, b) } +/// Whether Preserve Spot Colors applies with parameters `p`: `preserveSpotColors` is on and the +/// shading color is not Custom, which cannot keep a spot color. +fn preserves_spot_colors(p: &Value) -> bool { + flag(p, "preserveSpotColors", false) && text(p, "shadingColor", "black") != "custom" +} + +/// A face of `paint`, a color linked to a swatch, with Preserve Spot Colors: the swatch at a lower +/// tint for the highlight `spec`, its link kept so a spot color stays on its own plate, and, where +/// tone `tone` is below full light and the faces darken (`shade`), black over it at `1 - tone`, +/// multiplied and overprinting, so it prints on the black plate. `seam` is the width of the seam +/// stroke each layer draws around the face. +fn linked_face(paint: &Paint, tone: f64, spec: f64, shade: Option, seam: f64) -> Vec { + let Paint::Solid { color, swatch, tint } = paint else { return vec![] }; + let keep = (1.0 - spec).clamp(0.0, 1.0) as f32; + let fill = Paint::Solid { color: color.tinted(keep), swatch: swatch.clone(), tint: tint * keep }; + let mut items = vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(crate::lighting::seam_stroke(fill, seam))]; + if shade.is_some() && tone < 1.0 { + let black = Paint::solid(Color::gray((1.0 - tone).clamp(0.0, 1.0) as f32)); + let mut layer = FillLayer::new(black.clone()); + layer.blend = BlendMode::Multiply; + layer.overprint = true; + let mut edge = crate::lighting::seam_stroke(black, seam); + edge.blend = BlendMode::Multiply; + edge.overprint = true; + items.extend([AppearanceItem::Fill(layer), AppearanceItem::Stroke(edge)]); + } + items +} + /// The width of the stroke [`surface`] draws around `face` with parameters `p`: a wire edge on /// Wireframe, else the seam between faces. pub(crate) fn stroke_width(face: &Facet, p: &Value) -> f64 { @@ -200,12 +229,14 @@ pub(crate) fn stroke_width(face: &Facet, p: &Value) -> f64 { /// 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 tone and highlight (with Preserve Spot Colors, a color linked to a swatch as [`linked_face`] +/// draws it). A gradient with transparent stops is shaded opaque and then masked by /// its own transparency, so the shading follows the gradient's coverage. pub(crate) 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 preserve = preserves_spot_colors(p); let wire = shading.surface == ClassicSurface::Wireframe; let (paint, mask) = if wire { (source.clone(), None) } else { crate::lighting::opaque_paint(source) }; let paint = &paint; @@ -229,6 +260,10 @@ pub(crate) fn surface(n: &Node, facets: Vec, source: &Paint, opacity: f32 AppearanceItem::Stroke(crate::lighting::seam_stroke(paint.clone(), seam)), ] } + // Preserve Spot Colors: a color linked to a swatch stays a tint of that swatch. + Some(_) if preserve && matches!(paint, Paint::Solid { swatch: Some(_), .. }) && !paint.is_registration() => { + linked_face(paint, tone, spec, shade, seam) + } Some(c) => { let fill = Paint::solid(shaded(c, tone, spec, shade)); vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(crate::lighting::seam_stroke(fill, seam))] diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 945eb639aa..de1ec75de3 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -1,5 +1,5 @@ use serde_json::json; -use vectorcraft_doc::{Document, NodeId, StrokeLayer}; +use vectorcraft_doc::{Document, FillLayer, NodeId, StrokeLayer}; use vectorcraft_geom::{Affine, BezPath, Point, Shape}; use super::*; @@ -2015,6 +2015,62 @@ fn a_member_in_a_transparent_gradient_keeps_the_shared_face_order() { } } +/// The fills of each face of `art`: the first is the face's paint, a second the shadow over it. +fn face_fills(art: &Node) -> Vec> { + faces(art) + .iter() + .map(|f| { + f.appearance + .items + .iter() + .filter_map(|i| match i { + AppearanceItem::Fill(f) => Some(f.clone()), + AppearanceItem::Stroke(_) => None, + }) + .collect() + }) + .collect() +} + +#[test] +fn classic_preserve_spot_colors_keeps_a_linked_fill_on_every_face() { + let ink = Color::cmyk(0.0, 1.0, 0.0, 0.2); + let art = |params: Value| { + let mut n = classic(params); + n.appearance.items = vec![AppearanceItem::Fill(FillLayer::new(Paint::Solid { color: ink, swatch: Some("Ink".into()), tint: 1.0 }))]; + solid_art(&n).unwrap() + }; + let lit = json!({"preserveSpotColors": true, "highlightIntensity": 100, "highlightSize": 100, "lightHeight": 60}); + let (mut highlighted, mut shaded) = (false, false); + for layers in face_fills(&art(lit)) { + let Paint::Solid { color, swatch: Some(name), tint } = &layers[0].paint else { panic!("the swatch is kept: {layers:?}") }; + assert_eq!(name, "Ink"); + assert_eq!(*color, ink.tinted(*tint), "the color is the swatch at the face's tint"); + highlighted |= *tint < 1.0; + if let Some(shadow) = layers.get(1) { + let Some(Color::Gray { k }) = shadow.paint.color() else { panic!("black shading: {:?}", shadow.paint) }; + assert!(k > 0.0 && k <= 1.0, "{k}"); + assert_eq!((shadow.blend, shadow.overprint, shadow.opacity), (BlendMode::Multiply, true, 1.0)); + shaded = true; + } + assert!(layers.len() <= 2, "{layers:?}"); + } + assert!(highlighted && shaded, "the highlight lowers the tint and the shadow adds black"); + // No shading color: the highlight only, as tints of the swatch. + for layers in face_fills(&art(json!({"preserveSpotColors": true, "shadingColor": "none"}))) { + assert!(matches!(&layers[..], [f] if matches!(&f.paint, Paint::Solid { swatch: Some(_), .. })), "{layers:?}"); + } + // Off, or with a custom shading color, shaded faces are process mixes, as before. + for params in [json!({}), json!({"preserveSpotColors": true, "shadingColor": "custom", "shadingCustom": "#3366cc"})] { + let unlinked = face_fills(&art(params.clone())).iter().filter(|l| matches!(l[0].paint, Paint::Solid { swatch: None, .. })).count(); + assert!(unlinked > 0, "{params}"); + } + // An unlinked fill is shaded as it is with the option off. + let off: Vec<[f32; 3]> = classic_faces(json!({})).iter().map(fill_rgb).collect(); + let on: Vec<[f32; 3]> = classic_faces(json!({"preserveSpotColors": true})).iter().map(fill_rgb).collect(); + assert_eq!(off, on); +} + #[test] fn classic_parameters_are_checked() { let n = classic(json!({})); @@ -2034,6 +2090,7 @@ fn classic_parameters_are_checked() { (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!({"preserveSpotColors": "yes"}), "`preserveSpotColors` 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"), @@ -2110,6 +2167,7 @@ fn classic_rotate_parameters_are_checked() { (json!({"lightIntensity": 101}), "`lightIntensity` must be between 0 and 100 (got 101)"), (json!({"blendSteps": 0}), "`blendSteps` must be a whole number between 1 and 256 (got 0)"), (json!({"lightBehind": 1}), "`lightBehind` must be true or false"), + (json!({"preserveSpotColors": null}), "`preserveSpotColors` must be true or false"), (json!({"highlightSize": 50}), "unknown parameter `highlightSize`"), (json!({"drawHiddenFaces": true}), "unknown parameter `drawHiddenFaces`"), (json!({"shadow": true}), "unknown parameter `shadow`"), diff --git a/crates/engine/src/tests/tints.rs b/crates/engine/src/tests/tints.rs index 25d4cb0d4b..d5ac2161f3 100644 --- a/crates/engine/src/tests/tints.rs +++ b/crates/engine/src/tests/tints.rs @@ -126,6 +126,34 @@ fn spot_tints_print_their_percentage_of_the_plate() { assert!(pdf.contains("cs 0.4 scn"), "the tint is the Separation value"); } +/// Extrude & Bevel (Classic) with Preserve Spot Colors keeps a spot fill's plate in PDF: the faces +/// are tints of the Separation, and with the option off they are process colors. +#[test] +fn classic_3d_preserve_spot_colors_keeps_the_spot_plate_in_pdf() { + let pdf = |preserve: bool| { + let mut s = inks(); + rect(&mut s); + run(&mut s, "paint.setFill", json!({"swatch": "Ink"})); + run(&mut s, "paint.setStroke", json!({"none": true})); + let params = json!({"preserveSpotColors": preserve, "highlightIntensity": 100, "highlightSize": 100, "lightHeight": 60}); + run(&mut s, "effect.apply", json!({"effect": "threeD.classicExtrude", "params": params})); + let bytes = + vectorcraft_pdf::export(doc(&s), &vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() }) + .unwrap(); + String::from_utf8_lossy(&bytes).into_owned() + }; + // The Separation values the page sets ("/CS1 cs 0.6 scn"). + let tints = |pdf: &str| -> std::collections::BTreeSet { + pdf.split(" scn").filter_map(|s| s.rsplit("cs ").next().filter(|t| t.parse::().is_ok()).map(str::to_string)).collect() + }; + let kept = pdf(true); + assert!(kept.contains("/Separation/Ink/DeviceCMYK"), "the faces print on the Ink plate"); + assert!(tints(&kept).len() > 1, "the highlight prints lighter tints of the ink: {:?}", tints(&kept)); + assert!(kept.contains("/Multiply"), "the shading is black multiplied over the ink"); + // Off, only a face left unshaded keeps the swatch, at its full tint. + assert!(tints(&pdf(false)).len() <= 1, "without the option shaded faces are process mixes"); +} + #[test] fn global_mode_shifts_tints_of_linked_colours_only() { let mut s = inks(); diff --git a/crates/ui-egui/src/dialogs/three_d_classic.rs b/crates/ui-egui/src/dialogs/three_d_classic.rs index a3857c914a..22c18df97e 100644 --- a/crates/ui-egui/src/dialogs/three_d_classic.rs +++ b/crates/ui-egui/src/dialogs/three_d_classic.rs @@ -1,7 +1,7 @@ //! The own controls of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic) in the //! compact 3D dialog: the extrude and bevel options, or the revolve options, on the Object tab -//! (Rotate (Classic) has none), 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 +//! (Rotate (Classic) has none), and the surface with its lights, highlight, blend steps, shading +//! color and Preserve Spot Colors on the Lighting tab. Position (presets, rotation, perspective) is the Rotation tab every 3D //! effect shares. use serde_json::{Value, json}; @@ -41,8 +41,8 @@ pub(super) fn revolve_controls(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { } /// The surface, then for Diffuse and Plastic the lights, Ambient Light, the highlight (Plastic -/// only, and not for Rotate (Classic), which has no Plastic surface), Blend Steps and Shading Color; -/// Draw Hidden Faces last, for Extrude & Bevel (Classic) (Revolve (Classic) draws every face, and +/// only, and not for Rotate (Classic), which has no Plastic surface), Blend Steps, Shading Color and +/// Preserve Spot Colors (unavailable while Shading Color is Custom); Draw Hidden Faces last, for Extrude & Bevel (Classic) (Revolve (Classic) draws every face, and /// Rotate (Classic)'s plane is seen from either side). pub(super) fn surface_controls(ui: &mut egui::Ui, d: &mut Dialog) { let id = d.str("__effect"); @@ -69,9 +69,11 @@ pub(super) fn surface_controls(ui: &mut egui::Ui, d: &mut Dialog) { } number(ui, d, "blendSteps", tl!("Blend Steps"), (1.0, 256.0), "", 0, None); choice(ui, d, "shadingColor", tl!("Shading Color"), &options_of(effect, "shadingColor")); - if d.str("shadingColor") == "custom" { + let custom = d.str("shadingColor") == "custom"; + if custom { shading_custom(ui, d); } + ui.add_enabled_ui(!custom, |ui| toggle(ui, d, "preserveSpotColors", tl!("Preserve Spot Colors"), false)); }); if id == CLASSIC_EXTRUDE { toggle(ui, d, "drawHiddenFaces", tl!("Draw Hidden Faces"), false); diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 342a5171b6..4fcb1152e4 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -181,7 +181,7 @@ 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, overlapping faces drawn in the order of their depths where they overlap, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | | Extrude & Bevel, Inflate, Rotate | initial: live, round seam joins, 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, and Quick Actions › Expand as Wireframes ([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; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); the Materials tab's Graphics, document symbols mapped onto Extrude & Bevel's caps and onto a plane, moved with an artboard handle, and wrapped around Revolve's side ([three-d.md](three-d.md#graphics)); no base materials, graphics on Inflate or on Extrude & Bevel's walls and bevels, soft shadows or ray tracing | -| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, round seam joins, 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) initial: live, round seam joins, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, round seam joins, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Map Art: none | +| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, round seam joins, 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) initial: live, round seam joins, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, round seam joins, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Preserve Spot Colors in all three, unavailable with a Custom Shading Color ([Preserve Spot Color](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=260)); 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)) | diff --git a/docs/mcp.md b/docs/mcp.md index 8ca4121032..0da8fed984 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4199,6 +4199,13 @@ is tall), `bevelInside` (false adds the bevel around the shape, true carves it i - `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. +- `preserveSpotColors` (default false) is the Lighting tab's Preserve Spot Colors checkbox. When it is on, a fill or + stroke linked to a spot or global swatch stays linked on every face, so a spot color prints on its own plate in + separations and PDF. The highlight lowers the swatch's tint, and a face that darkens gets black at the shade's + strength over the swatch, multiplied and overprinting. These rules for drawing the shading are VectorCraft's own. The + option does nothing while `shadingColor` is `custom`, since a custom shading color cannot keep a spot color + ([Preserve Spot Color](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=260)). + Unlinked colors, gradients, patterns and the Wireframe surface draw as they do with it off. 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. @@ -4215,8 +4222,8 @@ for a full turn, and draws every face, back to front, with the classic surfaces. 0..360, default 360), `cap` (true closes the profile's open ends, false leaves the surface hollow), `offset` (points, 0..1000, default 0; the axis's distance from the profile, so a value above 0 makes a ring), `edge` (`left`, the default, or `right`: the edge of the profile's bounds the axis is at), `rotationX/Y/Z` and `perspectiveAngle` as -above, and `surface`, the lights, `ambient`, `highlightIntensity`, `highlightSize`, `blendSteps`, `shadingColor` and -`shadingCustom` as Extrude & Bevel (Classic) takes them. It has no `drawHiddenFaces`. The effect also goes on a group +above, and `surface`, the lights, `ambient`, `highlightIntensity`, `highlightSize`, `blendSteps`, `shadingColor`, +`shadingCustom` and `preserveSpotColors` as Extrude & Bevel (Classic) takes them. It has no `drawHiddenFaces`. The effect also goes on a group or layer, whose members turn about one shared axis, or on a fill or stroke. The public documentation describes the dialog ([Create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)) and its Angle (0 to 360), Cap, Offset (0 to 1000) and Left Edge or Right Edge settings @@ -4235,8 +4242,8 @@ changed. 3D (Classic) › Rotate (Classic)…. It turns every fill and stroke as one flat plane about the center of the object's bounds, as `threeD.rotate` does, each in its own paint, and draws the plane from either side. Parameters: `rotationX/Y/Z` and `perspectiveAngle` as above; `surface` (`diffuse`, the default, lights the art softly, and -`none` keeps the paint unlit); and the lights, `ambient`, `blendSteps`, `shadingColor` and `shadingCustom` as Extrude & -Bevel (Classic) takes them. It has no highlight, no `drawHiddenFaces` and no shadow. The effect also goes on a group +`none` keeps the paint unlit); and the lights, `ambient`, `blendSteps`, `shadingColor`, `shadingCustom` and +`preserveSpotColors` as Extrude & Bevel (Classic) takes them. It has no highlight, no `drawHiddenFaces` and no shadow. The effect also goes on a group or layer, whose members turn about one shared center, or on a fill or stroke. The public documentation describes the dialog's Position and Surface settings ([Rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)), diff --git a/docs/three-d.md b/docs/three-d.md index f12ca4675c..fe018791b5 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -502,8 +502,13 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. - **3D (Classic)**: Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic) are built, with their surface types and blend steps. Revolve (Classic) sweeps the surface Revolve sweeps, at 64 segments for a full turn, and draws every face back to front, so it has no Draw Hidden Faces setting. Rotate (Classic) turns the plane Rotate turns, seen - from either side, with the No Shading and Diffuse surfaces only. The track cube, map art, Turntable and 3D export - are separate, unbuilt items. + from either side, with the No Shading and Diffuse surfaces only. Preserve Spot Colors, on the Lighting tab of all + three, keeps a color linked to a spot or global swatch linked on every face: the highlight lowers its tint, and a + face that darkens gets black over it, multiplied and overprinting, so a spot color stays on its own plate. The + checkbox is unavailable while Shading Color is Custom + ([Preserve Spot Color](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=260)); how + the faces are shaded with it is VectorCraft's own. 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).