diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 5b785bee14..602e2d7fe9 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -189,8 +189,8 @@ pub fn effect_catalog() -> Vec { EXTRUDE, "Extrude & Bevel…", &["Effect", "3D and Materials"], - "{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).", - json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0}), + "{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the caps, see threeD.mapGraphic: symbol: name of a document symbol, face: front|back (front; the back reads left to right as seen from behind), x: pt (0; the offset of the graphic's center from the center of the object's bounds, in the face's own plane), y: pt (0), scale: % (1..10000, 100; of the symbol's own size), rotation: degrees (-360..360, 0; counterclockwise); drawn turned, foreshortened and shaded with the cap and clipped to it, on a solid cap only)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).", + json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"graphics":[]}), ), g( INFLATE, @@ -203,8 +203,8 @@ pub fn effect_catalog() -> Vec { ROTATE, "Rotate…", &["Effect", "3D and Materials"], - "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.", - json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0}), + "{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), graphics: array of 0..64 objects ([]; the 3D and Materials panel's Graphics on the plane, as Extrude & Bevel's, with face front only)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.", + json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"graphics":[]}), ), g( CLASSIC_EXTRUDE, diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index d5f0af894a..89b41df9e7 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -87,15 +87,21 @@ pub use revolve::{ CLASSIC_REVOLVE, REVOLVE, has_revolve, is_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in, }; pub use solids::{ - CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, INFLATE, ROTATE, ROTATE_SURFACES, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, - classic_extrude_options, classic_shading, extrude_options, has_solid, inflate_options, is_classic_three_d, is_three_d, solid_art, solid_art_in, - three_d_meshes, three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, three_d_wireframe_in, validate_solid, - validate_solid_in, validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, + CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, GRAPHICS, GraphicFrame, INFLATE, MAX_GRAPHICS, MappedGraphic, ROTATE, ROTATE_SURFACES, + ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, classic_shading, extrude_options, graphic_bounds, + graphic_frame, graphic_symbols, has_solid, inflate_options, is_classic_three_d, is_three_d, mapped_graphics, missing_symbol, + names_graphic_symbol, rename_graphic_symbol, rename_graphic_symbol_in, solid_art, solid_art_in, takes_graphics, three_d_meshes, + three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, three_d_wireframe_in, validate_solid, validate_solid_in, + validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, with_graphic, +}; +pub use solids::{ + GRAPHIC_MAX_OFFSET, GRAPHIC_SCALE_RANGE, appearance_graphic_symbols, graphic_frame_at, graphics_reshaped_after, rename_appearance_graphic_symbol, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use svg_filter::SVG_FILTER; pub use trap::TRAP; pub use vectorcraft_three_d::Mesh; +pub use vectorcraft_three_d::PlanarFace; pub use warp::{WARP_NET, WarpNet, WarpStyle, map_version, warp_point, warp_version}; mod catalog; diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 0bff1e8a52..83c8531d48 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -6,7 +6,7 @@ use serde_json::Value; use vectorcraft_doc::color::{BlendMode, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask}; use vectorcraft_geom::{FillRule, PathData, Rect}; -use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, Mesh, View}; +use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, Mesh, PlanarFace, View}; #[cfg(test)] use vectorcraft_doc::color::Color; @@ -20,6 +20,15 @@ pub use classic::{CLASSIC_EXTRUDE, CLASSIC_ROTATE, ROTATE_SURFACES, SHADING_COLO mod source; pub(crate) use source::{resolve, resolve_for}; +mod graphics; +pub use graphics::{ + GRAPHICS, GraphicFrame, MAX_GRAPHICS, MappedGraphic, graphic_symbols, mapped_graphics, missing_symbol, names_graphic_symbol, + rename_graphic_symbol, rename_graphic_symbol_in, takes_graphics, with_graphic, +}; +pub use graphics::{ + MAX_OFFSET as GRAPHIC_MAX_OFFSET, SCALE_RANGE as GRAPHIC_SCALE_RANGE, appearance_graphic_symbols, rename_appearance_graphic_symbol, +}; + pub(crate) mod shadow; pub(crate) mod wireframe; @@ -70,6 +79,12 @@ pub fn three_d_params_as(from: &str, params: &Value, to: &str) -> Value { } } } + // A plane has only a front, so graphics on the back cap are dropped. + if to == ROTATE + && let Some(Value::Array(list)) = out.get_mut(GRAPHICS) + { + list.retain(|g| g.get("face").and_then(Value::as_str) != Some(PlanarFace::Back.id())); + } Value::Object(out) } @@ -212,6 +227,7 @@ fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> { } crate::lighting::validate(p)?; shadow::validate(p)?; + graphics::validate(id, p)?; if let Some(v) = p.get("bevel") && !v.as_str().is_some_and(|s| s == "none" || BevelShape::parse(s).is_some()) { @@ -241,6 +257,9 @@ pub fn validate_solid_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) /// `item`, whose shadow is cast by that item's solid alone. fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, item: Option) -> Result<(), String> { validate_solid_params(id, p)?; + if let Some(doc) = doc { + graphics::validate_symbols(doc, p)?; + } let label = label_of(id); let n = &*source::resolve_for(doc, n, label, is_plane(id))?; if is_container(n) { @@ -434,6 +453,7 @@ pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> { /// [`validate_three_d`] with the symbols of `doc` ([`validate_solid_in`]). pub fn validate_three_d_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> { + graphics::validate(id, p)?; if crate::revolve::is_revolve(id) { crate::revolve::validate_in(doc, n, id, p) } else { validate_solid_in(doc, n, id, p) } } @@ -449,6 +469,7 @@ pub fn validate_three_d_at(n: &Node, id: &str, p: &Value, at: Option<(usize, usi /// [`validate_three_d_at`] with the symbols of `doc` ([`validate_solid_in`]). A symbol instance, a /// blend and a clipping group take a 3D effect as groups do: on the object itself. pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: Option<(usize, usize)>) -> Result<(), String> { + graphics::validate(id, p)?; match at { Some(_) if is_container(n) || source::resolves(n) => { Err("A 3D effect on a group or layer goes on the group itself, not on its fill or stroke".into()) @@ -463,6 +484,7 @@ pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Va /// 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> { + graphics::validate(id, p)?; if crate::revolve::is_revolve(id) { crate::revolve::validate_params_of(id, p) } else { validate_solid_params(id, p) } } @@ -709,8 +731,8 @@ type ShadowAndSurfaces = (Vec>, Vec>); /// blending mode, and those of a group it is in, apply to the surfaces of the members it holds /// ([`masked`]). The art comes in two parts: the effect's shadow ([`cast_shadow`], none with it /// off), to be drawn behind every surface of the object, and the surfaces. -fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option { - let art = surfaces_for(n, fx, shapes)?; +fn art_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile]) -> Option { + let art = surfaces_for(doc, n, fx, shapes)?; // A shadow past the limits draws the source path, as a solid past them does; commands report // the error ([`validate_solid_in`]). let shadow = match cast_shadow(&fx.id, &crate::merged_params(&fx.id, &fx.params), shapes).ok()? { @@ -786,8 +808,139 @@ fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result = (vectorcraft_three_d::ShadowCaster, vectorcraft_three_d::Shadow, Vec>); -/// [`art_for`] without the shadow: the solids' surfaces. -fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> { +/// The graphics of a 3D effect with parameters `p`, drawn on its faces ([`graphics::face_art`]): +/// for each planar face with a graphic, the art and the face. `caps` are the faces the viewer sees +/// of the ends of the object's solids, each with its end; `bounds` are the bounds the solids were +/// built with; `opacity` is the opacity of the fill or stroke those solids are painted with, which +/// the graphics take too. `elements` is the effect's count of drawn path elements, shared by every +/// call for one effect ([`graphics::face_art`]). +fn graphic_art( + doc: Option<&Document>, + id: NodeId, + (effect, p): (&str, &Value), + bounds: Rect, + caps: &[&Facet], + opacity: f32, + elements: &mut usize, +) -> Vec<(PlanarFace, Arc)> { + let list = graphics::mapped_graphics(p); + if list.is_empty() || !takes_graphics(effect) { + return vec![]; + } + let view = view_options(p); + PlanarFace::ALL + .into_iter() + .filter_map(|face| { + let on: Vec<&Facet> = caps.iter().copied().filter(|f| f.end == Some(face)).collect(); + let map = if effect == ROTATE { + vectorcraft_three_d::plane_face_map(bounds, view) + } else { + vectorcraft_three_d::extrude_face_map(bounds, extrude_options(p), view, face) + }?; + graphics::face_art(doc, id, &list, (face, &map), bounds.center(), &on, elements).map(|mut art| { + art.opacity *= opacity; + (face, Arc::new(art)) + }) + }) + .collect() +} + +/// A solid (a fill's or a stroke's) with ends the viewer sees: the index of its surface, its +/// member, the ends, the opacity and blending mode of its fill or stroke, and whether its surface +/// carries an opacity mask (a gradient's transparency under a colored light, [`surface`]). +struct Filled { + at: usize, + member: usize, + caps: Vec, + opacity: f32, + blend: BlendMode, + masked: bool, +} + +/// Does a surface in `paint` carry an opacity mask ([`surface`])? +fn masks_surface(paint: &Paint, colored: bool) -> bool { + colored && crate::lighting::opaque_paint(paint).1.is_some() +} + +/// The graphics of the solids `filled` ([`graphic_art`]), each with the index of the surface it is +/// drawn right after (the last of those solids holding its face) and its face. With `per_member` +/// (a member's opacity mask or blending mode applies to its surfaces) each member's solids get +/// graphics of their own, clipped to that member's caps, so they stay in that member's scopes. +/// All the groups share one limit on the path elements drawn: once it is reached, later groups +/// draw no graphics. +fn placed_graphics( + doc: Option<&Document>, + id: NodeId, + effect: (&str, &Value), + bounds: Rect, + filled: &[Filled], + per_member: bool, +) -> Vec<(usize, PlanarFace, Arc)> { + let mut groups: Vec> = vec![]; + for f in filled { + // Solids whose fills or strokes differ in opacity or blending mode get graphics of their + // own, each drawn as its fill or stroke is. A solid whose surface carries an opacity mask + // gets its own too, which takes that mask ([`with_graphics`]). + let joins = |first: &Filled| { + (!per_member || first.member == f.member) && first.opacity == f.opacity && first.blend == f.blend && !first.masked && !f.masked + }; + match groups.iter_mut().find(|g| g.first().is_some_and(|first| joins(first))) { + Some(g) => g.push(f), + None => groups.push(vec![f]), + } + } + let mut out = vec![]; + let mut elements = 0usize; + for group in groups { + let caps: Vec<&Facet> = group.iter().flat_map(|f| &f.caps).collect(); + let opacity = group.first().map_or(1.0, |f| f.opacity); + let blend = group.first().map_or(BlendMode::Normal, |f| f.blend); + for (face, mut art) in graphic_art(doc, id, effect, bounds, &caps, opacity, &mut elements) { + if let Some(f) = group.iter().rev().find(|f| f.caps.iter().any(|c| c.end == Some(face))) { + Arc::make_mut(&mut art).blend = blend; + out.push((f.at, face, art)); + } + } + } + out +} + +/// One solid's surface as [`surfaces_for`] builds it: its faces' depths, its art, its member's +/// scopes, and for each face the end of a solid it is, when the viewer sees it. +type BuiltSurface<'s> = (Vec, Node, &'s [Scope], Vec>); + +/// [`with_graphics`] of built surfaces, each drawn whole. +fn whole_surfaces<'s>(surfaces: Vec>, arts: &[(usize, PlanarFace, Arc)]) -> Vec<(&'s [Scope], Arc)> { + with_graphics(surfaces.into_iter().map(|(_, s, scopes, _)| (scopes, Arc::new(s))).collect(), arts) +} + +/// `surfaces` drawn whole, each with its scopes, and the graphics `arts` ([`placed_graphics`]) each +/// right after its surface, with that surface's scopes and its opacity mask (a gradient's +/// transparency), so a graphic is hidden where its cap is. +fn with_graphics<'s>(surfaces: Vec<(&'s [Scope], Arc)>, arts: &[(usize, PlanarFace, Arc)]) -> Vec<(&'s [Scope], Arc)> { + let mut out = Vec::with_capacity(surfaces.len() + arts.len()); + for (k, (scopes, s)) in surfaces.into_iter().enumerate() { + let mask = s.extras().mask.clone(); + out.push((scopes, s)); + out.extend(arts.iter().filter(|(at, ..)| *at == k).map(|(.., art)| { + let mut art = art.clone(); + if let Some(mask) = &mask { + Arc::make_mut(&mut art).extras_mut().mask = Some(mask.clone()); + } + (scopes, art) + })); + } + out +} + +/// The ends of `f` the viewer sees, for a fill's solid or a stroke's: where its graphics go. +fn seen_ends(f: &[Facet]) -> impl Iterator { + f.iter().filter(|f| f.end.is_some() && !f.away) +} + +/// [`art_for`] without the shadow: the solids' surfaces, with the graphics mapped onto their planar +/// faces drawn right after the last of the solids' surfaces that holds that face. +fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> { let b = union_bounds(shapes)?; let p = crate::merged_params(&fx.id, &fx.params); let classic = fx.id == CLASSIC_EXTRUDE || fx.id == CLASSIC_ROTATE; @@ -795,11 +948,14 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option Option = seen_ends(&f).cloned().collect(); + if !caps.is_empty() { + filled.push(Filled { at: out.len(), member, caps, opacity, blend, masked: masks_surface(&paint, colored) }); + } + } let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) }; out.push((s.scopes.as_slice(), Arc::new(art))); } } + let arts = placed_graphics(doc, n.id, (ROTATE, &p), b, &filled, masks); + let out = with_graphics(out, &arts); return Some(if masks { masked(n.id, out) } else { out.into_iter().map(|(_, s)| s).collect() }); } let (parts, bounds) = solid_parts(shapes, b); @@ -842,36 +1006,56 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option= 1.0 && blend == BlendMode::Normal }); - let surfaces: Vec<(Vec, Node, &[Scope])> = solids + let arts = if !graphics_on { + vec![] + } else { + let filled: Vec = solids + .iter() + .enumerate() + .filter_map(|(at, (item, member, _, f, shape))| { + let caps: Vec = seen_ends(f).cloned().collect(); + let (paint, opacity, blend) = paint_of(item, *member); + let masked = masks_surface(&paint, colored); + (!caps.is_empty()).then_some(Filled { at, member: *shape, caps, opacity, blend, masked }) + }) + .collect(); + placed_graphics(doc, n.id, (&fx.id, &p), bounds, &filled, masks) + }; + let surfaces: Vec = solids .into_iter() .map(|(item, member, scopes, f, shape)| { let closed = shapes.get(shape).is_some_and(|s| s.path.subpaths.iter().all(|p| p.closed)); let paints = Paints::of(item, closed); let depths: Vec = f.iter().map(|f| Placed::of(f, shape, paints)).collect(); + let ends = f.iter().map(|f| f.end.filter(|_| !f.away)).collect(); let (paint, opacity, blend) = paint_of(item, member); let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) }; - (depths, art, scopes) + (depths, art, scopes, ends) }) .collect(); if masks { - return Some(masked(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, Arc::new(s))).collect())); + return Some(masked(n.id, whole_surfaces(surfaces, &arts))); } // One order takes a plain group of faces from each solid, one face for each facet. With // colored lights a surface can instead be one clipped composite of all its facets. A composite // has no faces to order, so when any surface is one, every surface of the solid, masked ones // included, draws whole in member order. - let plain = surfaces.iter().all(|(depths, s, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())); - if !opaque || !plain { - return Some(surfaces.into_iter().map(|(_, s, _)| Arc::new(s)).collect()); - } + let plain = + surfaces.iter().all(|(depths, s, _, _)| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())); // A surface in a gradient with transparent stops carries an opacity mask: its faces go in runs - // ([`in_runs`]), and each run carries the mask. - let masks: Vec>> = surfaces.iter().map(|(_, s, _)| s.extras().mask.clone()).collect(); - let (mut placed, mut faces) = (vec![], vec![]); - for (surface, (depths, s, _)) in surfaces.into_iter().enumerate() { + // ([`in_runs`]), and each run carries the mask. With graphics as well, the surfaces draw whole, + // each graphic right after its surface. + let masks: Vec>> = surfaces.iter().map(|(_, s, _, _)| s.extras().mask.clone()).collect(); + let masked_faces = masks.iter().any(Option::is_some); + if !opaque || !plain || (masked_faces && !arts.is_empty()) { + return Some(whole_surfaces(surfaces, &arts).into_iter().map(|(_, s)| s).collect()); + } + let (mut placed, mut faces, mut ends) = (vec![], vec![], vec![]); + for (surface, (depths, s, _, e)) in surfaces.into_iter().enumerate() { if let NodeKind::Group { children, .. } = s.kind { placed.extend(depths); faces.extend(children.into_iter().map(|face| Some((surface, face)))); + ends.extend(e); } } let sealed = match fx.id.as_str() { @@ -880,8 +1064,18 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option extrude_options(&p).cap, }; let order = draw_order(&placed, sealed); - let faces: Vec<(usize, Arc)> = order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect(); - let children = if masks.iter().all(Option::is_none) { faces.into_iter().map(|(_, face)| face).collect() } else { in_runs(n.id, faces, &masks) }; + if masked_faces { + let faces: Vec<(usize, Arc)> = order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect(); + return Some(vec![Arc::new(Node::group(n.id, in_runs(n.id, faces, &masks)))]); + } + // Each face's graphics go right after the last of the faces they are drawn on. + let last: Vec> = + arts.iter().map(|(_, face, _)| order.iter().rposition(|k| ends.get(*k).copied().flatten() == Some(*face))).collect(); + let mut children = Vec::with_capacity(order.len() + arts.len()); + for (at, k) in order.into_iter().enumerate() { + children.extend(faces.get_mut(k).and_then(Option::take).map(|(_, face)| face)); + children.extend(last.iter().zip(&arts).filter(|(l, _)| **l == Some(at)).map(|(_, (.., art))| art.clone())); + } Some(vec![Arc::new(Node::group(n.id, children))]) } @@ -966,7 +1160,7 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { } let n = &*resolve(doc, n, "").ok()?; if is_container(n) { - return group_solid_art(n); + return group_solid_art(doc, n); } let (path, rule) = profile(n)?; let ctx = GeomContext::of(n); @@ -975,7 +1169,7 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { let remaining: Vec; if let Some((index, fx)) = solid(&n.appearance.effects) { let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx); - let (shadow, surfaces) = art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?; + let (shadow, surfaces) = art_for(doc, n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?; children.extend(shadow.into_iter().chain(surfaces)); // Later geometry effects act on the projected art as a whole. remaining = past_effect(&n.appearance.effects, index); @@ -991,7 +1185,7 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { for item in &paints { if let Some((index, fx)) = solid(item.effects()) { let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); - let (shadow, surfaces) = art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?; + let (shadow, surfaces) = art_for(doc, n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?; let later: Vec = item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect(); let with_later = |mut art: Arc| { Arc::make_mut(&mut art).appearance.effects = later.clone(); @@ -1015,11 +1209,11 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { /// geometry effects before it reshape the members, those after it bend the result as a whole; the /// group's own fills and strokes are not drawn. `None` when the effect is only on one of the /// group's own fills or strokes, or when its members can't become solids. -fn group_solid_art(n: &Node) -> Option { +fn group_solid_art(doc: Option<&Document>, n: &Node) -> Option { let (index, fx) = solid(&n.appearance.effects)?; let mut shapes = members(n, "").ok()?; reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes); - let (shadow, surfaces) = art_for(n, fx, &shapes)?; + let (shadow, surfaces) = art_for(doc, n, fx, &shapes)?; let children = shadow.into_iter().chain(surfaces).collect(); let remaining = past_effect(&n.appearance.effects, index); let mut art = n.clone(); @@ -1028,6 +1222,77 @@ fn group_solid_art(n: &Node) -> Option { Some(art) } +/// Where the graphics of the 3D effect of `n` (on the object itself with `item` `None`, else on its +/// fill or stroke `item`) are drawn: the bounds its solids are built with and the maps of their +/// planar faces ([`graphics::GraphicFrame`]). `None` when `n` has no visible Extrude & Bevel or +/// Rotate there, or its art can't be built. +pub fn graphic_frame(doc: Option<&Document>, n: &Node, item: Option) -> Option { + frame_of(doc, n, item, &solid) +} + +/// [`graphic_frame`] of the 3D effect at `index` in that list, which must be a visible Extrude & +/// Bevel or Rotate. +pub fn graphic_frame_at(doc: Option<&Document>, n: &Node, item: Option, index: usize) -> Option { + frame_of(doc, n, item, &|fx: &[Effect]| fx.get(index).filter(|e| e.visible && SOLIDS.contains(&e.id.as_str())).map(|e| (index, e))) +} + +/// Does a visible geometry effect come after the 3D effect at `index` in the effects of `n` (on the +/// object itself with `item` `None`, else on its fill or stroke `item`)? Such an effect moves or +/// bends the drawn art, graphics included, while [`graphic_frame_at`] places the graphics on the +/// art before it. +pub fn graphics_reshaped_after(n: &Node, item: Option, index: usize) -> bool { + n.appearance.effects_at(item).and_then(|fx| fx.get(index + 1..)).is_some_and(crate::has_geometry) +} + +/// Finds a 3D effect in a list of effects, with its index there. +type Pick<'p> = dyn for<'e> Fn(&'e [Effect]) -> Option<(usize, &'e Effect)> + 'p; + +/// [`graphic_frame`] of the 3D effect `pick` finds in the effects of `n` or its `item`. +fn frame_of(doc: Option<&Document>, n: &Node, item: Option, pick: &Pick<'_>) -> Option { + let n = &*resolve(doc, n, "").ok()?; + let (fx, shapes) = if is_container(n) { + let (index, fx) = pick(&n.appearance.effects).filter(|_| item.is_none())?; + let mut shapes = members(n, "").ok()?; + reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes); + (fx, shapes) + } else { + let (path, rule) = profile(n)?; + let ctx = GeomContext::of(n); + match item { + None => { + let (index, fx) = pick(&n.appearance.effects)?; + let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx); + (fx, vec![Profile { path, rule, items: solid_paints(n), opacity: 1.0, scopes: vec![] }]) + } + Some(k) => { + let it = n.appearance.items.get(k)?; + let (index, fx) = pick(it.effects())?; + let path = geometry(&geometry(&path, &n.appearance.effects, &ctx), it.effects().get(..index)?, &ctx.item(it)); + (fx, vec![Profile { path, rule, items: vec![it.clone()], opacity: 1.0, scopes: vec![] }]) + } + } + }; + if !takes_graphics(&fx.id) { + return None; + } + let p = crate::merged_params(&fx.id, &fx.params); + let b = union_bounds(&shapes)?; + let view = view_options(&p); + if fx.id == ROTATE { + let plane = vectorcraft_three_d::plane_face_map(b, view); + return Some(graphics::GraphicFrame::new(b, plane, None)); + } + let (_, bounds) = solid_parts(&shapes, b); + let options = extrude_options(&p); + let map = |face| vectorcraft_three_d::extrude_face_map(bounds, options, view, face); + Some(graphics::GraphicFrame::new(bounds, map(PlanarFace::Front), map(PlanarFace::Back))) +} + +/// The bounds of the art symbol `name` of `doc` draws as a graphic, in the symbol's coordinates. +pub fn graphic_bounds(doc: &Document, name: &str) -> Option { + graphics::art_bounds(doc, name) +} + /// `effects` without the 3D effect at `index` and the geometry effects before it, which shape the /// art it projects. Each raster effect before it follows the Transform effects among those /// ([`crate::without_geometry`]), unless a Transform effect after it makes copies or moves at diff --git a/crates/effects/src/solids/graphics.rs b/crates/effects/src/solids/graphics.rs new file mode 100644 index 0000000000..6c5d8686a7 --- /dev/null +++ b/crates/effects/src/solids/graphics.rs @@ -0,0 +1,631 @@ +//! Graphics mapped onto a 3D object's planar faces (Window › 3D and Materials › Materials › +//! Graphics): the art of a document symbol drawn on Extrude & Bevel's front or back cap, or on a +//! plane, turned, foreshortened and shaded with that face and clipped to it. +//! +//! The effect keeps its graphics in its `graphics` parameter, each `{symbol, face, x, y, scale, +//! rotation}`: the symbol's name, the face, the offset of the graphic's center from the center of +//! the object's bounds in the face's own plane (points, x to the right and y down as the face is +//! seen from the front of that face), its size as a share of the symbol's own, and its turn +//! (degrees, counterclockwise). On the back cap, x runs to the right as the back is seen from +//! behind, so the graphic reads the right way round there. + +use std::sync::Arc; + +use serde_json::{Map, Value, json}; +use vectorcraft_doc::color::{BlendMode, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol}; +use vectorcraft_geom::kurbo::{CubicBez, ParamCurve, PathEl, Shape}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, SubPath}; +use vectorcraft_three_d::{FaceMap, Facet, PlanarFace}; + +/// The effect parameter that holds the graphics. +pub const GRAPHICS: &str = "graphics"; + +/// The most graphics one 3D effect maps. +pub const MAX_GRAPHICS: usize = 64; + +/// The most fills and stroke outlines the art of one symbol draws as a graphic. +const MAX_PIECES: usize = 4096; + +/// The most path elements the graphics of one 3D effect draw together, after a perspective cuts +/// their curves. +const MAX_ELEMENTS: usize = 400_000; + +/// The pieces each curve of a graphic is cut into under a perspective map, which bends it. +const PERSPECTIVE_PIECES: usize = 8; + +/// How deep symbol instances in a graphic's art may nest. +const MAX_NESTING: usize = 8; + +/// How deep the objects of a graphic's art may be, counting groups and the art of the symbols it +/// places: as deep as an object may be drawn. +const MAX_LEVELS: usize = 4 * vectorcraft_doc::MAX_DEPTH; + +/// The size range of a graphic, as a percentage of its symbol's own size. +pub const SCALE_RANGE: (f64, f64) = (1.0, 10_000.0); + +/// How far a graphic's center may be from the center of its object, in points. +pub const MAX_OFFSET: f64 = 1e6; + +/// One graphic mapped onto a face of a 3D object. +#[derive(Clone, Debug, PartialEq)] +pub struct MappedGraphic { + pub symbol: String, + pub face: PlanarFace, + pub x: f64, + pub y: f64, + /// Percent of the symbol's own size. + pub scale: f64, + /// Degrees, counterclockwise. + pub rotation: f64, +} + +impl MappedGraphic { + pub fn to_value(&self) -> Value { + json!({"symbol": self.symbol, "face": self.face.id(), "x": self.x, "y": self.y, "scale": self.scale, "rotation": self.rotation}) + } + + /// The graphic `v` describes. Only `symbol` is required: `face` defaults to front, the offsets + /// and the rotation to 0 and the scale to 100. + pub fn from_value(v: &Value) -> Result { + let Value::Object(m) = v else { return Err(format!("each of `{GRAPHICS}` must be an object")) }; + if let Some(k) = m.keys().find(|k| !matches!(k.as_str(), "symbol" | "face" | "x" | "y" | "scale" | "rotation")) { + return Err(format!("`{GRAPHICS}` entries take symbol, face, x, y, scale and rotation, not `{k}`")); + } + let symbol = match m.get("symbol") { + Some(Value::String(s)) if !s.trim().is_empty() => s.clone(), + _ => return Err(format!("each of `{GRAPHICS}` needs `symbol`, the name of a symbol")), + }; + let face = match m.get("face") { + None => PlanarFace::Front, + Some(v) => v.as_str().and_then(PlanarFace::parse).ok_or_else(|| format!("`{GRAPHICS}` face must be front or back"))?, + }; + let number = |key: &str, default: f64, (lo, hi): (f64, f64)| -> Result { + match m.get(key) { + None => Ok(default), + Some(v) => v + .as_f64() + .filter(|n| n.is_finite() && (lo..=hi).contains(n)) + .ok_or_else(|| format!("`{GRAPHICS}` {key} must be a number from {lo} to {hi}")), + } + }; + Ok(Self { + symbol, + face, + x: number("x", 0.0, (-MAX_OFFSET, MAX_OFFSET))?, + y: number("y", 0.0, (-MAX_OFFSET, MAX_OFFSET))?, + scale: number("scale", 100.0, SCALE_RANGE)?, + rotation: number("rotation", 0.0, (-360.0, 360.0))?, + }) + } + + /// Where the graphic's art goes in the face's plane, for symbol art whose bounds are `art` on an + /// object whose bounds have center `center`. + fn placement(&self, art: Rect, center: Point) -> Affine { + let place = Affine::translate((center.x + self.x, center.y + self.y)) + * Affine::rotate(-self.rotation.to_radians()) + * Affine::scale(self.scale / 100.0) + * Affine::translate(-art.center().to_vec2()); + match self.face { + PlanarFace::Front => place, + // Seen from behind, the back's right is the front's left. + PlanarFace::Back => { + Affine::translate(center.to_vec2()) * Affine::scale_non_uniform(-1.0, 1.0) * Affine::translate(-center.to_vec2()) * place + } + } + } +} + +/// The 3D effects that take graphics: Extrude & Bevel (its caps) and Rotate (the plane), the two +/// object types of the 3D and Materials panel with planar faces. +pub fn takes_graphics(id: &str) -> bool { + id == super::EXTRUDE || id == super::ROTATE +} + +/// The graphics in parameters `p`, leaving out entries that are not valid (a file may hold +/// anything; commands check every entry with [`validate`]). +pub fn mapped_graphics(p: &Value) -> Vec { + match p.get(GRAPHICS) { + Some(Value::Array(list)) => list.iter().take(MAX_GRAPHICS).filter_map(|v| MappedGraphic::from_value(v).ok()).collect(), + _ => vec![], + } +} + +/// Are the graphics in parameters `p` of 3D effect `id` valid? Only Extrude & Bevel and Rotate take +/// graphics, and a plane has only a front. +pub fn validate(id: &str, p: &Value) -> Result<(), String> { + let list = match p.get(GRAPHICS) { + None => return Ok(()), + Some(Value::Array(list)) => list, + Some(_) => return Err(format!("`{GRAPHICS}` must be an array")), + }; + if list.is_empty() { + return Ok(()); + } + if !takes_graphics(id) { + return Err("Graphics map onto planar faces: use the Extrude or Plane object type".into()); + } + if list.len() > MAX_GRAPHICS { + return Err(format!("`{GRAPHICS}` takes at most {MAX_GRAPHICS} graphics")); + } + for v in list { + let g = MappedGraphic::from_value(v)?; + if id == super::ROTATE && g.face == PlanarFace::Back { + return Err("A plane has one face: map its graphics onto the front".into()); + } + } + Ok(()) +} + +/// The first symbol the graphics in `p` name that `doc` does not have, leaving out the symbols the +/// graphics in `old` (the effect's parameters before an edit) already name: a graphic whose symbol +/// was deleted may stay. +pub fn missing_symbol(doc: &Document, p: &Value, old: Option<&Value>) -> Option { + let kept = old.map(mapped_graphics).unwrap_or_default(); + mapped_graphics(p) + .into_iter() + .map(|g| g.symbol) + .find(|name| !doc.symbols.iter().any(|s| s.name == *name) && !kept.iter().any(|g| g.symbol == *name)) +} + +/// Does the art of the symbols the graphics in `p` name fit the limits? A graphic whose symbol +/// `doc` does not have draws nothing and is not counted. With a perspective, each curve counts as +/// the [`PERSPECTIVE_PIECES`] pieces it is cut into; without one, it counts once. +pub fn validate_symbols(doc: &Document, p: &Value) -> Result<(), String> { + let curve = if super::view_options(p).perspective > 0.0 { PERSPECTIVE_PIECES } else { 1 }; + let mut elements = 0usize; + for g in mapped_graphics(p) { + let Some(symbol) = doc.symbols.iter().find(|s| s.name == g.symbol) else { continue }; + let pieces = pieces_of(doc, symbol)?; + elements = elements.saturating_add(pieces.iter().map(|p| drawn_elements(&p.path, curve)).sum()); + if elements > MAX_ELEMENTS { + return Err("The graphics are too complex: simplify the symbols' art".into()); + } + } + Ok(()) +} + +/// The path elements `path` draws when each curve draws `curve` elements, before curves near the +/// eye are split further: one for each straight line, move and close. +fn drawn_elements(path: &PathData, curve: usize) -> usize { + let segments = |s: &SubPath| (0..s.segment_count()).map(|i| if s.segment_is_line(i) { 1 } else { curve }).sum::(); + path.subpaths.iter().map(|s| 1 + usize::from(s.closed) + segments(s)).sum() +} + +/// One area of a graphic's art, drawn with one paint: a fill, or a stroke's outline. +struct Piece { + path: PathData, + rule: FillRule, + paint: Paint, + opacity: f32, + blend: BlendMode, +} + +/// The areas the art of `symbol` paints, back to front, at the symbol's natural size: the fills of +/// its paths, compound paths and type (as outlines), and its strokes' outlines, through the symbol +/// instances it holds (with their changes to dynamic symbols, and through 9-slice guides). Hidden objects, guides, clipping paths and images are left out, and clipping +/// groups draw their art unclipped. +fn pieces_of(doc: &Document, symbol: &Symbol) -> Result, String> { + fn walk(doc: &Document, n: &Node, outer: f32, xf: Affine, (depth, level): (usize, usize), out: &mut Vec) -> Result<(), String> { + if !n.visible || n.is_guide() { + return Ok(()); + } + if level > MAX_LEVELS { + return Err(format!("A graphic's art may be nested at most {MAX_LEVELS} levels deep: simplify the symbol's art")); + } + let opacity = outer * n.opacity; + let shape = match &n.kind { + NodeKind::Path { path, rule, clipping: false, .. } => Some((path.clone(), *rule)), + NodeKind::Compound { children, rule } => { + Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule)) + } + NodeKind::Text(_) => { + if let Some(mut outlined) = crate::outline_text(n) { + // The outlines take the type's place, with its opacity. + outlined.visible = true; + outlined.opacity = n.opacity; + walk(doc, &outlined, outer, xf, (depth, level + 1), out)?; + } + None + } + NodeKind::SymbolInstance { symbol, xf: at } => { + if let Some(s) = doc.symbols.iter().find(|s| s.name == *symbol) { + if depth >= MAX_NESTING { + return Err(format!("A graphic's art may nest symbols at most {MAX_NESTING} levels deep: simplify the symbol's art")); + } + // The instance shows its own changes to a dynamic symbol's art, and a symbol + // with 9-slice guides is placed through them, as the canvas draws it. The + // pieces are not clipped, so paths that cross a guide are cut at it. + let art = vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n); + if doc.symbol_slice_map(symbol, *at).is_some() { + let Some(placed) = doc.place_symbol_art_unclipped(symbol, &art, *at) else { + return Err("A graphic's art holds too many objects: simplify the symbol's art".into()); + }; + walk(doc, &placed, opacity, xf, (depth + 1, level + 1), out)?; + } else { + walk(doc, &art, opacity, xf * *at, (depth + 1, level + 1), out)?; + } + } + None + } + _ => { + for c in n.children().into_iter().flatten() { + walk(doc, c, opacity, xf, (depth, level + 1), out)?; + } + None + } + }; + let Some((path, rule)) = shape else { return Ok(()) }; + // Symbol art keeps its strokes' widths as drawn while its geometry is scaled to the + // symbol's square, so a stroke is outlined after the art takes its size. + let path = path.transformed(xf); + for item in n.appearance.items.iter().filter(|i| i.visible()) { + let (area, rule, paint, item_opacity, blend) = match item { + AppearanceItem::Fill(f) => (path.clone(), rule, f.paint.clone(), f.opacity, f.blend), + AppearanceItem::Stroke(s) if s.width > 0.0 && s.width.is_finite() => { + (crate::stroke::outline_region(&path, rule, s), FillRule::EvenOdd, s.paint.clone(), s.opacity, s.blend) + } + AppearanceItem::Stroke(_) => continue, + }; + if matches!(paint, Paint::None) || area.bounds().is_none() { + continue; + } + if out.len() >= MAX_PIECES { + return Err(format!("A graphic draws at most {MAX_PIECES} fills and strokes: simplify the symbol's art")); + } + // A placed gradient or pattern takes the same size and place as its path. + let mut paint = paint; + match &mut paint { + Paint::Gradient(g) => g.transform(xf), + Paint::Pattern { xf: placed, .. } => *placed = xf * *placed, + Paint::None | Paint::Solid { .. } => {} + } + out.push(Piece { path: area, rule, paint, opacity: opacity * item_opacity, blend }); + } + Ok(()) + } + let mut out = vec![]; + walk(doc, &symbol.art, 1.0, doc.symbol_natural_xf(&symbol.name), (0, 1), &mut out)?; + Ok(out) +} + +/// `path` through `map`: exactly for an affine map, otherwise with each curve cut into +/// [`PERSPECTIVE_PIECES`] pieces whose control points go through it (straight lines stay straight +/// under a perspective). Fails when part of it has no page point, or when it takes more than `room` +/// elements. +fn map_path(path: &PathData, map: &FaceMap, affine: Option, room: usize) -> Result { + let bp = path.to_bezpath(); + if let Some(a) = affine { + return if bp.elements().len() <= room { Ok(a * bp) } else { Err(NotMapped::Full) }; + } + let to = |p: Point| map.to_page(p).ok_or(NotMapped::Behind); + let mut out = BezPath::new(); + let mut at = Point::ZERO; + for el in bp.elements() { + if out.elements().len() > room { + return Err(NotMapped::Full); + } + match *el { + PathEl::MoveTo(p) => { + out.move_to(to(p)?); + at = p; + } + PathEl::LineTo(p) => { + out.line_to(to(p)?); + at = p; + } + PathEl::QuadTo(c, p) => { + cubic_to(&mut out, vectorcraft_geom::kurbo::QuadBez::new(at, c, p).raise(), map).ok_or(NotMapped::Behind)?; + at = p; + } + PathEl::CurveTo(c1, c2, p) => { + cubic_to(&mut out, CubicBez::new(at, c1, c2, p), map).ok_or(NotMapped::Behind)?; + at = p; + } + PathEl::ClosePath => out.close_path(), + } + } + if out.elements().len() <= room { Ok(out) } else { Err(NotMapped::Full) } +} + +/// Why [`map_path`] drew nothing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum NotMapped { + /// Part of the path has no page point: it is at or behind the eye. + Behind, + /// The path takes more elements than it has room for. + Full, +} + +/// Curve `c` through `map`, cut into [`PERSPECTIVE_PIECES`] pieces. A piece that lies in front of +/// the eye while a control point does not is split further ([`piece_to`]). `None` when part of a +/// piece has no page point. +fn cubic_to(out: &mut BezPath, c: CubicBez, map: &FaceMap) -> Option<()> { + for k in 0..PERSPECTIVE_PIECES { + let (t0, t1) = (k as f64 / PERSPECTIVE_PIECES as f64, (k + 1) as f64 / PERSPECTIVE_PIECES as f64); + let s = c.subsegment(t0..t1); + if !map.maps_curve([s.p0, s.p1, s.p2, s.p3]) { + return None; + } + piece_to(out, s, map, MAX_SPLITS)?; + } + Some(()) +} + +/// How many times [`piece_to`] halves a piece whose control points are behind the eye. +const MAX_SPLITS: usize = 4; + +/// Piece `s`, which lies in front of the eye, through `map`: halved until the control points of +/// each half have page points, at most `splits` times, and drawn straight past that. +fn piece_to(out: &mut BezPath, s: CubicBez, map: &FaceMap, splits: usize) -> Option<()> { + let end = map.to_page(s.p3)?; + match (map.to_page(s.p1), map.to_page(s.p2)) { + (Some(c1), Some(c2)) => out.curve_to(c1, c2, end), + _ if splits == 0 => out.line_to(end), + _ => { + let (a, b) = s.subdivide(); + piece_to(out, a, map, splits - 1)?; + piece_to(out, b, map, splits - 1)?; + } + } + Some(()) +} + +/// The graphics `graphics` lists for `face`, drawn on caps `caps` (the faces of that end of the +/// object's solids that the viewer sees) through `map`, for an object whose bounds have +/// center `center`: one clipping group of every such graphic, shaded with the first cap's light. +/// Pattern tiles keep their colors: the document's pattern draws them. A graphic whose symbol +/// `doc` does not have draws nothing. `elements` counts the path elements +/// the effect's graphics have drawn so far, after a perspective cuts their curves, and is shared by +/// every call for one effect. `None` when there is nothing to draw or that count passes +/// [`MAX_ELEMENTS`]. +pub(super) fn face_art( + doc: Option<&Document>, + id: NodeId, + graphics: &[MappedGraphic], + (face, map): (PlanarFace, &FaceMap), + center: Point, + caps: &[&Facet], + elements: &mut usize, +) -> Option { + let doc = doc?; + let light = caps.first()?.illumination; + let affine = map.local_affine(center).filter(|_| map.is_affine()); + let mut children = vec![]; + for g in graphics.iter().filter(|g| g.face == face) { + let Some(symbol) = doc.symbols.iter().find(|s| s.name == g.symbol) else { continue }; + // A symbol past the limits is left out; the other graphics still draw. + let Ok(pieces) = pieces_of(doc, symbol) else { continue }; + let Some(bounds) = pieces.iter().filter_map(|p| p.path.bounds()).reduce(|a, b| a.union(b)) else { continue }; + let place = g.placement(bounds, center); + // Gradients follow the face's map as it is near the graphic's center. + let middle = place * bounds.center(); + // When the center is at or behind the eye, they follow it near the middle of the part that + // is in front; a graphic with no part in front is left out. + let local = map.local_affine(middle).or_else(|| { + let part = map.mapped_part(place.transform_rect_bbox(bounds))?; + let sum = part.iter().fold(Point::ZERO, |s, p| s + p.to_vec2()); + map.local_affine(Point::new(sum.x / part.len() as f64, sum.y / part.len() as f64)) + }); + let Some(local) = local else { continue }; + let gradient_map = local * place; + for piece in pieces { + let placed = piece.path.transformed(place); + // Each anchor draws at least one element. A perspective cuts curves into more, and + // straight lines stay one each: the path may take what is left of the limit, and is + // counted as it is drawn. Past the limit, the face draws nothing. + let least: usize = placed.subpaths.iter().map(|s| s.anchors.len()).sum(); + let before = *elements; + if before.saturating_add(least) > MAX_ELEMENTS { + return None; + } + let room = MAX_ELEMENTS - before; + let mut rule = piece.rule; + let mapped = match map_path(&placed, map, affine, room) { + Err(NotMapped::Behind) => { + // Part of it is at or behind the eye: the part in front is drawn. + let Some(part) = placed.bounds().and_then(|b| map.mapped_part(b)) else { continue }; + let Some(&first) = part.first() else { continue }; + let mut front = BezPath::new(); + front.move_to(first); + part.iter().skip(1).for_each(|p| front.line_to(*p)); + front.close_path(); + let cut = vectorcraft_pathops::boolean( + &placed, + piece.rule, + &PathData::from_bezpath(&front), + FillRule::NonZero, + vectorcraft_pathops::BoolOp::Intersect, + ); + rule = FillRule::NonZero; + map_path(&cut, map, affine, room) + } + done => done, + }; + let mapped = match mapped { + Ok(mapped) => mapped, + Err(NotMapped::Full) => return None, + Err(NotMapped::Behind) => continue, + }; + *elements = before.saturating_add(mapped.elements().len()); + let mut paint = piece.paint; + vectorcraft_doc::recolor::map_paint(&mut paint, &|c| crate::lighting::shade(c, light)); + // A pattern's tiles follow the graphic onto the face, as its gradients do below. + if let Paint::Pattern { xf, .. } = &mut paint { + *xf = gradient_map * *xf; + } + let mut fill = FillLayer::new(paint); + fill.opacity = piece.opacity; + fill.blend = piece.blend; + let mut appearance = Appearance { items: vec![AppearanceItem::Fill(fill)], ..Default::default() }; + appearance.transform_gradients(gradient_map); + let mut node = Node::path(id, PathData::from_bezpath(&mapped), appearance); + if let NodeKind::Path { rule: r, .. } = &mut node.kind { + *r = rule; + } + children.push(Arc::new(node)); + } + } + if children.is_empty() { + return None; + } + let mut clip = BezPath::new(); + for cap in caps { + // A cap's holes are contours of the same path wound against its outline, so turning the + // whole path keeps them holes under the nonzero rule. + let mut outline = cap.path().to_bezpath(); + if outline.area() < 0.0 { + outline = outline.reverse_subpaths(); + } + clip.extend(outline.iter()); + } + let clip = Node::path(id, PathData::from_bezpath(&clip), Appearance::default()); + Some(Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(Node::group(id, children))], clip: true })) +} + +/// The bounds of the art symbol `name` of `doc` draws as a graphic, in the symbol's coordinates. +pub(super) fn art_bounds(doc: &Document, name: &str) -> Option { + let symbol = doc.symbols.iter().find(|s| s.name == name)?; + pieces_of(doc, symbol).ok()?.iter().filter_map(|p| p.path.bounds()).reduce(|a, b| a.union(b)) +} + +/// Where a 3D object's graphics are drawn: the bounds its solids are built with (the graphics' +/// offsets are from their center) and the maps of its front and back faces, when it has them. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GraphicFrame { + pub bounds: Rect, + front: Option, + back: Option, +} + +impl GraphicFrame { + pub(super) fn new(bounds: Rect, front: Option, back: Option) -> Self { + Self { bounds, front, back } + } + + pub fn map(&self, face: PlanarFace) -> Option<&FaceMap> { + match face { + PlanarFace::Front => self.front.as_ref(), + PlanarFace::Back => self.back.as_ref(), + } + } + + /// The point of `face`'s plane that offset `(x, y)` names, as [`MappedGraphic`] places a + /// graphic's center. + fn art_point(&self, face: PlanarFace, (x, y): (f64, f64)) -> Point { + let c = self.bounds.center(); + match face { + PlanarFace::Front => Point::new(c.x + x, c.y + y), + PlanarFace::Back => Point::new(c.x - x, c.y + y), + } + } + + /// Where graphic `g`'s center lands on the page, and whether its face is turned toward the + /// viewer. `None` when the object has no such face or the point has no page point. + pub fn handle(&self, g: &MappedGraphic) -> Option<(Point, bool)> { + let map = self.map(g.face)?; + Some((map.to_page(self.art_point(g.face, (g.x, g.y)))?, map.visible)) + } + + /// The offset of a graphic on `face` whose center lands on page point `page`. + pub fn offset_at(&self, face: PlanarFace, page: Point) -> Option<(f64, f64)> { + let art = self.map(face)?.to_art(page)?; + let c = self.bounds.center(); + let (x, y) = match face { + PlanarFace::Front => (art.x - c.x, art.y - c.y), + PlanarFace::Back => (c.x - art.x, art.y - c.y), + }; + (x.abs() <= MAX_OFFSET && y.abs() <= MAX_OFFSET).then_some((x, y)) + } + + /// The scale (percent) at which art with bounds `art` fits inside the object's bounds, at most + /// 100: where a newly mapped graphic starts. + pub fn fitting_scale(&self, art: Rect) -> f64 { + let fit = (self.bounds.width() / art.width().max(1e-9)).min(self.bounds.height() / art.height().max(1e-9)); + (fit * 100.0).clamp(SCALE_RANGE.0, 100.0) + } +} + +/// The symbols the graphics of `n`'s 3D effects name (its own effects and its fills' and strokes'). +pub fn graphic_symbols(n: &Node) -> Vec { + appearance_graphic_symbols(&n.appearance) +} + +/// The symbols the graphics of the 3D effects in `appearance` name, as [`graphic_symbols`]: for a +/// graphic style's appearance. +pub fn appearance_graphic_symbols(appearance: &Appearance) -> Vec { + std::iter::once(&appearance.effects) + .chain(appearance.items.iter().map(AppearanceItem::effects)) + .flatten() + .filter(|e| takes_graphics(&e.id)) + .flat_map(|e| mapped_graphics(&e.params)) + .map(|g| g.symbol) + .collect() +} + +/// Makes the graphics of `n`'s 3D effects that name symbol `from` name `to` instead. Returns +/// whether any changed. +pub fn rename_graphic_symbol(n: &mut Node, from: &str, to: &str) -> bool { + // Checked first, so an appearance that names another symbol is not copied. + graphic_symbols(n).iter().any(|s| s == from) && rename_appearance_graphic_symbol(&mut n.appearance, from, to) +} + +/// [`rename_graphic_symbol`] for the 3D effects in `appearance`: for a graphic style's appearance. +pub fn rename_appearance_graphic_symbol(appearance: &mut Appearance, from: &str, to: &str) -> bool { + if !appearance_graphic_symbols(appearance).iter().any(|s| s == from) { + return false; + } + let mut lists: Vec<&mut Vec> = vec![]; + let (effects, items) = (&mut appearance.effects, &mut appearance.items); + lists.push(effects); + lists.extend(items.iter_mut().map(AppearanceItem::effects_mut)); + for e in lists.into_iter().flatten().filter(|e| takes_graphics(&e.id)) { + if let Some(Value::Array(list)) = e.params.get_mut(GRAPHICS) { + for g in list.iter_mut() { + if g.get("symbol").and_then(Value::as_str) == Some(from) { + g["symbol"] = json!(to); + } + } + } + } + true +} + +/// Does `n` or any object in it, opacity masks included, have a graphic that names symbol `from`? +pub fn names_graphic_symbol(n: &Node, from: &str) -> bool { + let mut found = false; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |c| found |= graphic_symbols(c).iter().any(|s| s == from)); + found +} + +/// [`rename_graphic_symbol`] for `n` and every object in it, at any depth, opacity masks included. +/// Only the parts that name `from` are copied. Returns whether any changed. +pub fn rename_graphic_symbol_in(n: &mut Node, from: &str, to: &str) -> bool { + let names = names_graphic_symbol; + let mut changed = rename_graphic_symbol(n, from, to); + if let Some(mask) = n.extras_if_any().and_then(|x| x.mask.as_deref_mut()).filter(|m| names(&m.art, from)) { + changed |= rename_graphic_symbol_in(Arc::make_mut(&mut mask.art), from, to); + } + if let Some(children) = n.children_mut() { + for c in children.iter_mut().filter(|c| names(c, from)) { + changed |= rename_graphic_symbol_in(Arc::make_mut(c), from, to); + } + } + changed +} + +/// `p` with graphic `g` added at the end of its list. +pub fn with_graphic(p: &Value, g: &MappedGraphic) -> Value { + let mut out = p.as_object().cloned().unwrap_or_else(Map::new); + let mut list = match out.get(GRAPHICS) { + Some(Value::Array(list)) => list.clone(), + _ => vec![], + }; + list.push(g.to_value()); + out.insert(GRAPHICS.into(), Value::Array(list)); + Value::Object(out) +} + +#[cfg(test)] +#[path = "tests_graphics.rs"] +mod tests; diff --git a/crates/effects/src/solids/tests_graphics.rs b/crates/effects/src/solids/tests_graphics.rs new file mode 100644 index 0000000000..64c947a9df --- /dev/null +++ b/crates/effects/src/solids/tests_graphics.rs @@ -0,0 +1,659 @@ +use serde_json::json; +use vectorcraft_doc::color::Color; +use vectorcraft_doc::{StrokeLayer, Symbol}; +use vectorcraft_geom::shapes; + +use super::super::{EXTRUDE, INFLATE, ROTATE, graphic_frame, solid_art_in, three_d_params_as, validate_three_d_in, validate_three_d_params}; +use super::*; + +fn red() -> Color { + Color::rgb(1.0, 0.0, 0.0) +} + +fn square(id: u64, r: Rect, paint: Paint) -> Node { + Node::path(NodeId(id), shapes::rectangle(r), Appearance::basic(paint, Paint::None, 0.0)) +} + +/// A document with symbol "Mark": a red 10 × 10 square. +fn document() -> Document { + let mut d = Document::new(1000.0, 1000.0); + d.symbols.push(Symbol { + dynamic: false, + name: "Mark".into(), + art: Arc::new(square(0, Rect::new(500.0, 500.0, 510.0, 510.0), Paint::solid(red()))), + }); + d +} + +/// A gray 100 × 100 square at (0, 0) with effect `id`, its parameters `params` over a view +/// straight on. +fn object(id: &str, params: Value) -> Node { + let mut p = json!({"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0, "shade": false}); + if let (Some(p), Some(extra)) = (p.as_object_mut(), params.as_object()) { + p.extend(extra.clone()); + } + let mut n = square(7, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(0.5, 0.5, 0.5))); + n.appearance.effects.push(crate::new_effect(id, &p).unwrap()); + n +} + +/// The graphics clip groups in `art`, in drawing order: each one's mapped paths. +fn graphics_in(art: &Node) -> Vec> { + let mut out = vec![]; + art.walk(&mut |n| { + if let NodeKind::Group { children, clip: true } = &n.kind + && let [_, inner] = children.as_slice() + && let NodeKind::Group { children: paths, clip: false } = &inner.kind + { + let paths: Vec<(PathData, Paint)> = paths + .iter() + .filter_map(|p| { + let fill = p.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Fill(f) => Some(f.paint.clone()), + AppearanceItem::Stroke(_) => None, + })?; + Some((p.path_data()?.clone(), fill)) + }) + .collect(); + if paths.iter().any(|(_, p)| p.color().is_some_and(|c| c.to_rgb()[1] < 0.1 && c.to_rgb()[0] > 0.3)) { + out.push(paths); + } + } + }); + out +} + +fn bounds_of(paths: &[(PathData, Paint)]) -> Rect { + paths.iter().filter_map(|(p, _)| p.bounds()).reduce(|a, b| a.union(b)).unwrap() +} + +fn close(a: Rect, b: Rect) -> bool { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(p, q)| (p - q).abs() < 1e-6) +} + +#[test] +fn a_graphic_is_drawn_on_the_front_cap_at_its_offset_size_and_turn() { + let d = document(); + let n = object(EXTRUDE, json!({"graphics": [{"symbol": "Mark", "x": 20.0, "y": -10.0, "scale": 200.0}]})); + let art = solid_art_in(Some(&d), &n).unwrap(); + let found = graphics_in(&art); + assert_eq!(found.len(), 1); + // Straight on, the front cap is the art: a 20 × 20 square centered 20 right of and 10 above (50, 50). + assert!(close(bounds_of(&found[0]), Rect::new(60.0, 30.0, 80.0, 50.0)), "{:?}", bounds_of(&found[0])); + // Turned 45°, the square's corners reach out to its half diagonal. + let n = object(EXTRUDE, json!({"graphics": [{"symbol": "Mark", "rotation": 45.0}]})); + let b = bounds_of(&graphics_in(&solid_art_in(Some(&d), &n).unwrap())[0]); + assert!((b.width() - 10.0 * 2f64.sqrt()).abs() < 1e-6); +} + +#[test] +fn without_the_document_or_its_symbol_nothing_is_drawn() { + let n = object(EXTRUDE, json!({"graphics": [{"symbol": "Mark"}]})); + let art = solid_art_in(None, &n).unwrap(); + assert!(graphics_in(&art).is_empty()); + let n = object(EXTRUDE, json!({"graphics": [{"symbol": "Gone"}]})); + assert!(graphics_in(&solid_art_in(Some(&document()), &n).unwrap()).is_empty()); + // Without graphics, the art is the same as before they existed. + let plain = object(EXTRUDE, json!({})); + assert_eq!(solid_art_in(Some(&document()), &plain), solid_art_in(None, &plain)); +} + +#[test] +fn the_graphic_is_shaded_with_its_face() { + let d = document(); + let n = object(EXTRUDE, json!({"shade": true, "lightIntensity": 0.0, "ambient": 50.0, "graphics": [{"symbol": "Mark"}]})); + let found = graphics_in(&solid_art_in(Some(&d), &n).unwrap()); + let c = found[0][0].1.color().unwrap().to_rgb(); + assert!((c[0] - 0.5).abs() < 1e-3 && c[1].abs() < 1e-6 && c[2].abs() < 1e-6, "{c:?}"); +} + +#[test] +fn the_graphic_goes_right_after_its_cap_and_is_clipped_to_it() { + let d = document(); + let n = object(EXTRUDE, json!({"rotationX": -20.0, "rotationY": 25.0, "graphics": [{"symbol": "Mark", "scale": 2000.0}]})); + let art = solid_art_in(Some(&d), &n).unwrap(); + let NodeKind::Group { children, .. } = &art.kind else { panic!("a group") }; + let NodeKind::Group { children: faces, .. } = &children.last().unwrap().kind else { panic!("the ordered faces") }; + let last = faces.last().unwrap(); + assert!(matches!(&last.kind, NodeKind::Group { clip: true, .. }), "the graphic is drawn last, over the front cap"); + let NodeKind::Group { children: parts, .. } = &last.kind else { panic!() }; + let clip = parts[0].path_data().unwrap().bounds().unwrap(); + let cap = faces[faces.len() - 2].path_data().unwrap().bounds().unwrap(); + assert!(close(clip, cap), "clipped to the cap: {clip:?} against {cap:?}"); +} + +#[test] +fn the_back_cap_reads_the_right_way_round_from_behind() { + let d = document(); + let behind = json!({"rotationY": 180.0, "graphics": [{"symbol": "Mark", "face": "back", "x": 20.0}]}); + let n = object(EXTRUDE, behind); + let found = graphics_in(&solid_art_in(Some(&d), &n).unwrap()); + assert_eq!(found.len(), 1); + let b = bounds_of(&found[0]); + // Seen from behind, x runs to the right on the page too. + assert!((b.center().x - 70.0).abs() < 1e-6, "{b:?}"); + let frame = graphic_frame(Some(&d), &n, None).unwrap(); + let g = MappedGraphic::from_value(&json!({"symbol": "Mark", "face": "back", "x": 20.0})).unwrap(); + let (at, visible) = frame.handle(&g).unwrap(); + assert!(visible && (at.x - 70.0).abs() < 1e-6); + let (x, y) = frame.offset_at(PlanarFace::Back, Point::new(40.0, 55.0)).unwrap(); + assert!((x + 10.0).abs() < 1e-6 && (y - 5.0).abs() < 1e-6); + // From the front the back cap is hidden, and so is its graphic. + let front = object(EXTRUDE, json!({"graphics": [{"symbol": "Mark", "face": "back"}]})); + assert!(graphics_in(&solid_art_in(Some(&d), &front).unwrap()).is_empty()); +} + +#[test] +fn a_perspective_foreshortens_the_graphic_with_its_face() { + let d = document(); + let params = json!({"rotationX": -30.0, "rotationY": 35.0, "perspectiveAngle": 100.0, "graphics": [{"symbol": "Mark", "scale": 400.0}]}); + let n = object(EXTRUDE, params); + let found = graphics_in(&solid_art_in(Some(&d), &n).unwrap()); + let frame = graphic_frame(Some(&d), &n, None).unwrap(); + let map = frame.map(PlanarFace::Front).unwrap(); + // The corners of the 40 × 40 square centered on the object land where the face's map takes them. + let path = &found[0][0].0; + let corners: Vec = [(30.0, 30.0), (70.0, 30.0), (70.0, 70.0), (30.0, 70.0)].map(|(x, y)| map.to_page(Point::new(x, y)).unwrap()).into(); + for c in corners { + let near = path.subpaths.iter().flat_map(|s| &s.anchors).map(|a| a.p.distance(c)).fold(f64::INFINITY, f64::min); + assert!(near < 1e-6, "{c:?} is {near} from the drawn graphic"); + } +} + +#[test] +fn a_graphic_behind_the_eye_leaves_the_others_drawn() { + let d = document(); + let view = json!({"rotationX": -60.0, "rotationY": 0.0, "perspectiveAngle": 160.0}); + let n = object(EXTRUDE, view.clone()); + let frame = graphic_frame(Some(&d), &n, None).unwrap(); + let map = frame.map(PlanarFace::Front).unwrap(); + let c = frame.bounds.center(); + // An offset whose point of the front's plane is at or behind the eye. + let y = [1.0, -1.0] + .into_iter() + .flat_map(|s| (1..60).map(move |k| s * 10f64.powf(f64::from(k) / 10.0))) + .find(|y| map.local_affine(Point::new(c.x, c.y + y)).is_none()) + .expect("an offset behind the eye"); + let mut params = view; + params["graphics"] = json!([{"symbol": "Mark"}, {"symbol": "Mark", "y": y}]); + let found = graphics_in(&solid_art_in(Some(&d), &object(EXTRUDE, params)).unwrap()); + assert_eq!(found.len(), 1, "the graphic that projects is still drawn"); + // A graphic centered there but large enough to reach over the cap draws the part in front. + let mut params = json!({"rotationX": -60.0, "rotationY": 0.0, "perspectiveAngle": 160.0}); + params["graphics"] = json!([{"symbol": "Mark", "y": y, "scale": (20.0 * (y.abs() + 50.0)).clamp(1.0, 10_000.0)}]); + let found = graphics_in(&solid_art_in(Some(&d), &object(EXTRUDE, params)).unwrap()); + assert_eq!(found.len(), 1, "the part in front is drawn (offset {y})"); + assert!(!found[0].is_empty()); +} + +#[test] +fn the_graphic_takes_the_opacity_of_its_fill() { + let d = document(); + for id in [EXTRUDE, ROTATE] { + let mut n = object(id, json!({"graphics": [{"symbol": "Mark"}]})); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.opacity = 0.25; + } + let art = solid_art_in(Some(&d), &n).unwrap(); + let mut opacities = vec![]; + art.walk(&mut |c| { + if matches!(c.kind, NodeKind::Group { clip: true, .. }) { + opacities.push(c.opacity); + } + }); + assert_eq!(opacities, vec![0.25], "{id}"); + } +} + +#[test] +fn the_graphic_takes_the_blending_mode_of_its_fill() { + let d = document(); + for id in [EXTRUDE, ROTATE] { + let mut n = object(id, json!({"graphics": [{"symbol": "Mark"}]})); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.blend = BlendMode::Multiply; + } + let art = solid_art_in(Some(&d), &n).unwrap(); + let mut blends = vec![]; + art.walk(&mut |c| { + if matches!(c.kind, NodeKind::Group { clip: true, .. }) { + blends.push(c.blend); + } + }); + assert_eq!(blends, vec![BlendMode::Multiply], "{id}"); + } +} + +#[test] +fn each_fill_of_a_plane_gives_its_graphic_its_own_opacity() { + let d = document(); + let mut n = object(ROTATE, json!({"graphics": [{"symbol": "Mark"}]})); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.opacity = 0.25; + } + let mut second = FillLayer::new(Paint::solid(Color::rgb(0.2, 0.2, 0.2))); + second.opacity = 0.75; + n.appearance.items.push(AppearanceItem::Fill(second)); + let art = solid_art_in(Some(&d), &n).unwrap(); + let mut opacities = vec![]; + art.walk(&mut |c| { + if matches!(c.kind, NodeKind::Group { clip: true, .. }) { + opacities.push(c.opacity); + } + }); + opacities.sort_by(f32::total_cmp); + assert_eq!(opacities, vec![0.25, 0.75]); +} + +#[test] +fn a_symbols_gradient_takes_the_size_of_its_art() { + use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; + let mut d = document(); + // Symbol art kept in its 20 × 20 square, drawn 40 × 20. + let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, red()), GradientStop::new(1.0, Color::BLACK)])); + g.geom = Some(GradientGeom { start: Point::new(-10.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }); + let art = square(0, Rect::new(-10.0, -10.0, 10.0, 10.0), Paint::Gradient(Box::new(g))); + d.symbols.push(Symbol { dynamic: false, name: "Ramp".into(), art: Arc::new(art) }); + d.unknown.insert(vectorcraft_doc::SYMBOL_SIZES.into(), json!({"Ramp": [40.0, 20.0]})); + let n = object(EXTRUDE, json!({"graphics": [{"symbol": "Ramp"}]})); + let out = solid_art_in(Some(&d), &n).unwrap(); + let mut found = None; + out.walk(&mut |c| { + if let (Some(p), Some(AppearanceItem::Fill(f))) = (c.path_data(), c.appearance.items.first()) + && let Paint::Gradient(g) = &f.paint + { + found = Some((p.bounds().unwrap(), g.geom.unwrap())); + } + }); + let (bounds, geom) = found.unwrap(); + assert!(close(bounds, Rect::new(30.0, 40.0, 70.0, 60.0)), "{bounds:?}"); + assert!(geom.start.distance(Point::new(30.0, 50.0)) < 1e-6 && geom.end.distance(Point::new(70.0, 50.0)) < 1e-6, "{geom:?}"); +} + +#[test] +fn a_symbols_pattern_follows_the_graphic() { + let mut d = document(); + let tiles = Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }; + d.symbols.push(Symbol { dynamic: false, name: "Tiles".into(), art: Arc::new(square(0, Rect::new(-10.0, -10.0, 10.0, 10.0), tiles)) }); + let n = object(EXTRUDE, json!({"graphics": [{"symbol": "Tiles", "scale": 200.0}]})); + let out = solid_art_in(Some(&d), &n).unwrap(); + let mut found = None; + out.walk(&mut |c| { + if let Some(AppearanceItem::Fill(f)) = c.appearance.items.first() + && let Paint::Pattern { xf, .. } = &f.paint + { + found = Some((c.path_data().unwrap().bounds().unwrap(), *xf)); + } + }); + let (bounds, xf) = found.unwrap(); + // The 20 × 20 art drawn 40 × 40 on the middle of the front: its tiles go with it. + assert!(close(bounds, Rect::new(30.0, 30.0, 70.0, 70.0)), "{bounds:?}"); + assert!((xf * Point::new(-10.0, -10.0)).distance(Point::new(30.0, 30.0)) < 1e-6, "{xf:?}"); + assert!((xf * Point::new(10.0, 10.0)).distance(Point::new(70.0, 70.0)) < 1e-6, "{xf:?}"); +} + +#[test] +fn a_curve_in_front_of_the_eye_maps_though_its_control_points_do_not() { + let bounds = Rect::new(10.0, 20.0, 70.0, 60.0); + let view = vectorcraft_three_d::View { rotation_x: -89.0, rotation_y: 40.0, rotation_z: 10.0, perspective: 160.0, ..Default::default() }; + let map = vectorcraft_three_d::plane_face_map(bounds, view).unwrap(); + // Where the page map stops along the line x = `x`, between the center and a point it does not map. + let edge = |x: f64| { + let inside = Point::new(x, bounds.center().y); + let far = [Point::new(x, -1e6), Point::new(x, 1e6)].into_iter().find(|p| map.to_page(*p).is_none()).unwrap(); + let (mut a, mut b) = (inside, far); + for _ in 0..200 { + let m = a.midpoint(b); + if map.to_page(m).is_some() { a = m } else { b = m } + } + a + }; + let (b1, b2) = (edge(40.0), edge(60.0)); + let u = (b2 - b1).normalize(); + let mut n = vectorcraft_geom::Vec2::new(-u.y, u.x); + if map.to_page(b1 + n * 10.0).is_none() { + n = -n; + } + // Ends 219.17 in front of that line and control points 100 and 42 past it: the curve stays + // about 1 in front, while a control point of one of the pieces it is cut into falls behind. + let at = |s: f64, t: f64| b1 + n * s + u * t; + let mut curve = BezPath::new(); + curve.move_to(at(219.17, 0.0)); + curve.curve_to(at(-100.0, 5.0), at(-42.0, 15.0), at(219.17, 20.0)); + let c = CubicBez::new(at(219.17, 0.0), at(-100.0, 5.0), at(-42.0, 15.0), at(219.17, 20.0)); + assert!((0..=1000).all(|k| map.to_page(c.eval(k as f64 / 1000.0)).is_some()), "the curve itself is in front"); + let pieces = (0..PERSPECTIVE_PIECES).map(|k| c.subsegment(k as f64 / 8.0..(k + 1) as f64 / 8.0)); + assert!(pieces.flat_map(|s| [s.p1, s.p2]).any(|p| map.to_page(p).is_none()), "a piece's control point is behind"); + let mapped = map_path(&PathData::from_bezpath(&curve), &map, None, usize::MAX).expect("the curve maps"); + // The split pieces count toward the room the path is given: without room, the path is full, + // not behind the eye, so it is not clipped. + assert!(mapped.elements().len() > 1 + PERSPECTIVE_PIECES); + assert_eq!(map_path(&PathData::from_bezpath(&curve), &map, None, PERSPECTIVE_PIECES + 1).err(), Some(NotMapped::Full)); + assert!(mapped.elements().iter().all(|e| e.end_point().is_none_or(|p| p.x.is_finite() && p.y.is_finite()))); + // That piece is split until its halves' control points map, so it stays a curve. + assert!(mapped.elements().iter().all(|e| !matches!(e, PathEl::LineTo(_))), "{mapped:?}"); + // With its ends at 214.25 instead, the curve dips about 0.24 behind the line between the ends + // of two pieces, which still map: it has no page point there, and is left to the clipping. + let c = CubicBez::new(at(214.25, 0.0), at(-100.0, 5.0), at(-42.0, 15.0), at(214.25, 20.0)); + assert!((0..=PERSPECTIVE_PIECES).all(|k| map.to_page(c.eval(k as f64 / 8.0)).is_some())); + assert!((0..=1000).any(|k| map.to_page(c.eval(k as f64 / 1000.0)).is_none())); + let mut dips = BezPath::new(); + dips.move_to(c.p0); + dips.curve_to(c.p1, c.p2, c.p3); + assert_eq!(map_path(&PathData::from_bezpath(&dips), &map, None, usize::MAX).err(), Some(NotMapped::Behind)); +} + +#[test] +fn members_with_their_own_blending_get_graphics_of_their_own() { + let d = document(); + let a = square(1, Rect::new(0.0, 0.0, 50.0, 100.0), Paint::solid(Color::rgb(0.5, 0.5, 0.5))); + let mut b = square(2, Rect::new(50.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(0.5, 0.5, 0.5))); + b.blend = BlendMode::Multiply; + let mut group = Node::group(NodeId(3), vec![Arc::new(a), Arc::new(b)]); + let p = json!({"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0, "shade": false, "graphics": [{"symbol": "Mark", "scale": 1000.0}]}); + group.appearance.effects.push(crate::new_effect(EXTRUDE, &p).unwrap()); + let art = solid_art_in(Some(&d), &group).unwrap(); + let found = graphics_in(&art); + // One graphic clipped to each member's cap: the second stays inside the member's Multiply group. + assert_eq!(found.len(), 2); + let mut blended = 0; + art.walk(&mut |c| { + if c.blend == BlendMode::Multiply { + blended += graphics_in(c).len(); + } + }); + assert_eq!(blended, 1); +} + +/// [`document`] with symbol "Dense" too: a red polygon of 30,000 sides, straight or `curved`. +fn dense_document(curved: bool) -> Document { + let mut d = document(); + let mut poly = BezPath::new(); + let mut last = Point::ZERO; + for k in 0..30_000 { + let a = k as f64 / 30_000.0 * std::f64::consts::TAU; + let p = Point::new(5.0 + 5.0 * a.cos(), 5.0 + 5.0 * a.sin()); + if k == 0 { + poly.move_to(p); + } else if curved { + poly.curve_to(last.lerp(p, 1.0 / 3.0), last.lerp(p, 2.0 / 3.0), p); + } else { + poly.line_to(p); + } + last = p; + } + poly.close_path(); + let art = Node::path(NodeId(0), PathData::from_bezpath(&poly), Appearance::basic(Paint::solid(red()), Paint::None, 0.0)); + d.symbols.push(Symbol { dynamic: false, name: "Dense".into(), art: Arc::new(art) }); + d +} + +/// The graphics drawn on a group of two members, each with its own graphic of symbol "Dense" +/// ([`dense_document`]), seen under a perspective. +fn dense_graphics_drawn(curved: bool) -> usize { + let d = dense_document(curved); + let a = square(1, Rect::new(0.0, 0.0, 50.0, 100.0), Paint::solid(Color::rgb(0.5, 0.5, 0.5))); + let mut b = square(2, Rect::new(50.0, 0.0, 100.0, 100.0), Paint::solid(Color::rgb(0.5, 0.5, 0.5))); + b.blend = BlendMode::Multiply; + let mut group = Node::group(NodeId(3), vec![Arc::new(a), Arc::new(b)]); + let p = json!({"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0, "perspectiveAngle": 60.0, "shade": false, "graphics": [{"symbol": "Dense", "scale": 1000.0}]}); + group.appearance.effects.push(crate::new_effect(EXTRUDE, &p).unwrap()); + graphics_in(&solid_art_in(Some(&d), &group).unwrap()).len() +} + +#[test] +fn members_with_graphics_of_their_own_share_one_element_limit() { + // A perspective cuts each of the 30,000 curves into 8: one copy fits the limit, two do not. + // The first member's graphic is drawn; the second would pass the limit, so it draws none. + assert_eq!(dense_graphics_drawn(true), 1); +} + +#[test] +fn straight_sides_count_once_under_a_perspective() { + // A perspective keeps each of the 30,000 straight sides one element: both copies fit the limit. + assert_eq!(dense_graphics_drawn(false), 2); +} + +#[test] +fn the_symbol_check_counts_what_the_view_draws() { + // Two graphics of the 30,000-sided polygon under a perspective: straight, they fit the limit; + // curved, they do not. + let two = json!({"perspectiveAngle": 60.0, "graphics": [{"symbol": "Dense"}, {"symbol": "Dense", "face": "back"}]}); + let n = object(EXTRUDE, two.clone()); + assert!(validate_three_d_in(Some(&dense_document(false)), &n, EXTRUDE, &two).is_ok()); + let e = validate_three_d_in(Some(&dense_document(true)), &n, EXTRUDE, &two).unwrap_err(); + assert!(e.contains("too complex"), "{e}"); + // Without a perspective each curve draws once: curved, they fit too. + let flat = json!({"perspectiveAngle": 0.0, "graphics": two["graphics"].clone()}); + assert!(validate_three_d_in(Some(&dense_document(true)), &n, EXTRUDE, &flat).is_ok()); +} + +#[test] +fn a_plane_and_a_hollow_solid() { + let d = document(); + let plane = object(ROTATE, json!({"rotationY": 30.0, "graphics": [{"symbol": "Mark"}]})); + assert_eq!(graphics_in(&solid_art_in(Some(&d), &plane).unwrap()).len(), 1); + let hollow = object(EXTRUDE, json!({"cap": false, "graphics": [{"symbol": "Mark"}]})); + assert!(graphics_in(&solid_art_in(Some(&d), &hollow).unwrap()).is_empty()); +} + +#[test] +fn a_stroked_graphic_draws_its_stroke_as_an_outline() { + let mut d = document(); + let mut ring = square(0, Rect::new(0.0, 0.0, 10.0, 10.0), Paint::None); + ring.appearance = Appearance { items: vec![AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(red()), 2.0))], ..Default::default() }.into(); + d.symbols.push(Symbol { dynamic: false, name: "Ring".into(), art: Arc::new(ring) }); + let n = object(EXTRUDE, json!({"graphics": [{"symbol": "Ring"}]})); + let found = graphics_in(&solid_art_in(Some(&d), &n).unwrap()); + // The 10 × 10 square's 2 pt stroke reaches 1 pt outside it. + assert!(close(bounds_of(&found[0]), Rect::new(44.0, 44.0, 56.0, 56.0)), "{:?}", bounds_of(&found[0])); +} + +#[test] +fn graphics_are_checked() { + let n = object(EXTRUDE, json!({})); + let check = |id: &str, graphics: Value| validate_three_d_in(None, &n, id, &json!({"graphics": graphics})); + assert!(check(EXTRUDE, json!([{"symbol": "Mark", "face": "back", "x": 3.0, "scale": 50.0, "rotation": -90.0}])).is_ok()); + assert!(check(EXTRUDE, json!([])).is_ok()); + for bad in [ + json!("Mark"), + json!([{"face": "front"}]), + json!([{"symbol": ""}]), + json!([{"symbol": "Mark", "face": "side"}]), + json!([{"symbol": "Mark", "scale": 0.0}]), + json!([{"symbol": "Mark", "rotation": 400.0}]), + json!([{"symbol": "Mark", "x": "1"}]), + json!([{"symbol": "Mark", "repeat": true}]), + json!(vec![json!({"symbol": "Mark"}); MAX_GRAPHICS + 1]), + ] { + assert!(check(EXTRUDE, bad.clone()).is_err(), "{bad}"); + } + assert!(check(ROTATE, json!([{"symbol": "Mark", "face": "back"}])).is_err()); + assert!(check(INFLATE, json!([{"symbol": "Mark"}])).is_err()); + assert!(validate_three_d_params(crate::REVOLVE, &json!({"graphics": [{"symbol": "Mark"}]})).is_err()); + // A loaded file's invalid entries are left out when drawn. + assert_eq!(mapped_graphics(&json!({"graphics": [{"symbol": "Mark"}, {"face": "back"}, 3]})).len(), 1); +} + +#[test] +fn the_frame_at_an_index_is_that_effects() { + let d = document(); + let mut n = object(EXTRUDE, json!({})); + n.appearance.effects.push(crate::new_effect(ROTATE, &json!({})).unwrap()); + // The first 3D effect is the Extrude & Bevel, with a back cap; the second is a plane, without. + assert!(graphic_frame(Some(&d), &n, None).unwrap().map(PlanarFace::Back).is_some()); + assert!(super::super::graphic_frame_at(Some(&d), &n, None, 0).unwrap().map(PlanarFace::Back).is_some()); + assert!(super::super::graphic_frame_at(Some(&d), &n, None, 1).unwrap().map(PlanarFace::Back).is_none()); + assert!(super::super::graphic_frame_at(Some(&d), &n, None, 2).is_none()); +} + +#[test] +fn a_change_to_a_plane_drops_the_back_caps_graphics() { + let p = json!({"graphics": [{"symbol": "Mark", "face": "front"}, {"symbol": "Mark", "face": "back"}]}); + let plane = three_d_params_as(EXTRUDE, &p, ROTATE); + assert_eq!(plane["graphics"], json!([{"symbol": "Mark", "face": "front"}])); + assert!(validate_three_d_params(ROTATE, &plane).is_ok()); + // Back to Extrude & Bevel, the front's graphic stays. + assert_eq!(three_d_params_as(ROTATE, &plane, EXTRUDE)["graphics"], plane["graphics"]); +} + +#[test] +fn a_stroked_solid_carries_its_graphic_too() { + let d = document(); + let mut n = Node::path( + NodeId(7), + shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), + Appearance::basic(Paint::None, Paint::solid(Color::rgb(0.5, 0.5, 0.5)), 20.0), + ); + let p = json!({"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0, "shade": false, "graphics": [{"symbol": "Mark", "scale": 2000.0}]}); + n.appearance.effects.push(crate::new_effect(EXTRUDE, &p).unwrap()); + assert_eq!(graphics_in(&solid_art_in(Some(&d), &n).unwrap()).len(), 1); +} + +#[test] +fn art_nested_too_deep_is_refused_and_not_drawn() { + let mut d = document(); + let mut art = square(0, Rect::new(0.0, 0.0, 10.0, 10.0), Paint::solid(red())); + for k in 0..=MAX_LEVELS { + art = Node::group(NodeId(k as u64 + 1), vec![Arc::new(art)]); + } + d.symbols.push(Symbol { dynamic: false, name: "Deep".into(), art: Arc::new(art) }); + let n = object(EXTRUDE, json!({"graphics": [{"symbol": "Deep"}]})); + let e = validate_three_d_in(Some(&d), &n, EXTRUDE, &json!({"graphics": [{"symbol": "Deep"}]})).unwrap_err(); + assert!(e.contains("nested"), "{e}"); + assert!(graphics_in(&solid_art_in(Some(&d), &n).unwrap()).is_empty()); + // A loaded file draws its other graphics. + let both = object(EXTRUDE, json!({"graphics": [{"symbol": "Deep"}, {"symbol": "Mark"}]})); + assert_eq!(graphics_in(&solid_art_in(Some(&d), &both).unwrap()).len(), 1); +} + +#[test] +fn symbols_nested_too_deep_are_refused_and_not_drawn() { + let mut d = document(); + // Symbol `N{k}` holds an instance of `N{k-1}`, and `N0` is a square. + d.symbols.push(Symbol { dynamic: false, name: "N0".into(), art: Arc::new(square(0, Rect::new(0.0, 0.0, 10.0, 10.0), Paint::solid(red()))) }); + for k in 1..=MAX_NESTING + 1 { + let instance = Node::new(NodeId(k as u64), NodeKind::SymbolInstance { symbol: format!("N{}", k - 1), xf: Affine::IDENTITY }); + d.symbols.push(Symbol { dynamic: false, name: format!("N{k}"), art: Arc::new(instance) }); + } + let deepest = format!("N{MAX_NESTING}"); + let p = json!({"graphics": [{"symbol": deepest}]}); + assert!(validate_three_d_in(Some(&d), &object(EXTRUDE, p.clone()), EXTRUDE, &p).is_ok()); + assert_eq!(graphics_in(&solid_art_in(Some(&d), &object(EXTRUDE, p)).unwrap()).len(), 1); + let past = format!("N{}", MAX_NESTING + 1); + let p = json!({"graphics": [{"symbol": past}]}); + let e = validate_three_d_in(Some(&d), &object(EXTRUDE, p.clone()), EXTRUDE, &p).unwrap_err(); + assert!(e.contains("nest"), "{e}"); + assert!(graphics_in(&solid_art_in(Some(&d), &object(EXTRUDE, p)).unwrap()).is_empty()); +} + +#[test] +fn a_solid_in_a_transparent_gradient_keeps_its_graphic() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let d = document(); + let mut stop = GradientStop::new(1.0, red()); + stop.opacity = 0.25; + let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, red()), stop])); + // Under a colored light the faces are drawn opaque and masked by the gradient's transparency. + let mut n = object(EXTRUDE, json!({"shade": true, "lights": [{"color": "#ff8080"}], "graphics": [{"symbol": "Mark"}]})); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.paint = Paint::Gradient(Box::new(gradient)); + } + let art = solid_art_in(Some(&d), &n).unwrap(); + let mut masked = false; + art.walk(&mut |c| masked |= c.extras().mask.is_some()); + assert!(masked); + assert_eq!(graphics_in(&art).len(), 1); + // Each graphic clip group in `art`, in drawing order: whether it carries an opacity mask. + let masks = |art: &Node| { + let mut out = vec![]; + art.walk(&mut |c| { + if let NodeKind::Group { children, clip: true } = &c.kind + && let [_, inner] = children.as_slice() + && matches!(inner.kind, NodeKind::Group { clip: false, .. }) + { + out.push(c.extras().mask.is_some()); + } + }); + out + }; + // The graphic carries the cap's mask, so it is hidden where the gradient is transparent. + assert_eq!(masks(&art), vec![true]); + // With an opaque stroke drawn after the fill, the fill's cap keeps a masked graphic of its own + // and the stroke's outline gets an unmasked one. + n.appearance.items.push(AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0))); + assert_eq!(masks(&solid_art_in(Some(&d), &n).unwrap()), vec![true, false]); +} + +#[test] +fn a_cap_with_a_hole_keeps_the_hole_out_of_the_graphic() { + let d = document(); + let outer = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); + let hole = shapes::rectangle(Rect::new(30.0, 30.0, 70.0, 70.0)).to_bezpath().reverse_subpaths(); + let mut ring = outer; + ring.extend(hole.iter()); + for flip in [false, true] { + let ring = if flip { ring.reverse_subpaths() } else { ring.clone() }; + let mut n = + Node::path(NodeId(7), PathData::from_bezpath(&ring), Appearance::basic(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), Paint::None, 0.0)); + let p = json!({"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0, "shade": false, "graphics": [{"symbol": "Mark", "scale": 2000.0}]}); + n.appearance.effects.push(crate::new_effect(EXTRUDE, &p).unwrap()); + let art = solid_art_in(Some(&d), &n).unwrap(); + let mut clips = vec![]; + art.walk(&mut |c| { + if let NodeKind::Group { children, clip: true } = &c.kind { + clips.push(children[0].path_data().unwrap().to_bezpath()); + } + }); + assert_eq!(clips.len(), 1, "flip {flip}"); + assert_ne!(clips[0].winding(Point::new(10.0, 10.0)), 0, "flip {flip}: the ring is inside the clip"); + assert_eq!(clips[0].winding(Point::new(50.0, 50.0)), 0, "flip {flip}: the hole is outside it"); + } +} + +#[test] +fn a_graphic_starts_inside_its_object() { + let d = document(); + let frame = graphic_frame(Some(&d), &object(EXTRUDE, json!({})), None).unwrap(); + assert_eq!(frame.fitting_scale(Rect::new(0.0, 0.0, 10.0, 10.0)), 100.0); + assert!((frame.fitting_scale(Rect::new(0.0, 0.0, 400.0, 200.0)) - 25.0).abs() < 1e-9); + assert_eq!(super::super::graphic_bounds(&d, "Mark"), Some(Rect::new(500.0, 500.0, 510.0, 510.0))); +} + +#[test] +fn a_symbol_instance_in_a_graphic_draws_as_the_canvas_shows_it() { + let mut d = document(); + let blue = Paint::solid(Color::rgb(0.0, 0.0, 1.0)); + // A dynamic symbol whose instance in "Changed" fills its square blue instead of red. + d.symbols.push(Symbol { dynamic: true, name: "Dynamic".into(), art: Arc::new(square(0, Rect::new(0.0, 0.0, 10.0, 10.0), Paint::solid(red()))) }); + let mut changed = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "Dynamic".into(), xf: Affine::IDENTITY }); + changed.extras_mut().symbol_overrides = + vec![vectorcraft_doc::symbol_overrides::SymbolOverride { fill: Some(blue.clone()), ..Default::default() }]; + d.symbols.push(Symbol { dynamic: false, name: "Changed".into(), art: Arc::new(changed) }); + let pieces = pieces_of(&d, d.symbols.iter().find(|s| s.name == "Changed").unwrap()).unwrap(); + assert_eq!(pieces.iter().map(|p| p.paint.clone()).collect::>(), [blue]); + // A symbol with 9-slice guides, a 100 × 100 square with a 10 × 10 one in its corner, placed + // three times as wide: the corner keeps its width while the rest stretches. + let corner = square(2, Rect::new(0.0, 0.0, 10.0, 10.0), Paint::solid(red())); + let whole = square(3, Rect::new(0.0, 0.0, 100.0, 100.0), Paint::solid(red())); + d.symbols.push(Symbol { dynamic: false, name: "Sliced".into(), art: Arc::new(Node::group(NodeId(4), vec![Arc::new(whole), Arc::new(corner)])) }); + d.set_symbol_nine_slice("Sliced", Some(vectorcraft_doc::SymbolNineSlice::default())); + let wide = Node::new(NodeId(5), NodeKind::SymbolInstance { symbol: "Sliced".into(), xf: Affine::scale_non_uniform(3.0, 1.0) }); + d.symbols.push(Symbol { dynamic: false, name: "Wide".into(), art: Arc::new(wide) }); + let pieces = pieces_of(&d, d.symbols.iter().find(|s| s.name == "Wide").unwrap()).unwrap(); + let widths: Vec = pieces.iter().map(|p| p.path.bounds().unwrap().width()).collect(); + assert_eq!(widths.len(), 2); + assert!((widths[1] / widths[0] - 1.0 / 30.0).abs() < 1e-6, "{widths:?}"); + // With a gradient, the square crossing the guides is cut at them: its pieces cover the + // placed 300 × 100 once, not one whole copy for each cell. + use vectorcraft_doc::color::{Gradient, GradientPaint}; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let shaded = square(6, Rect::new(0.0, 0.0, 100.0, 100.0), gradient); + d.symbols.push(Symbol { dynamic: false, name: "Shaded".into(), art: Arc::new(Node::group(NodeId(7), vec![Arc::new(shaded)])) }); + d.set_symbol_nine_slice("Shaded", Some(vectorcraft_doc::SymbolNineSlice::default())); + let wide = Node::new(NodeId(8), NodeKind::SymbolInstance { symbol: "Shaded".into(), xf: Affine::scale_non_uniform(3.0, 1.0) }); + d.symbols.push(Symbol { dynamic: false, name: "WideShaded".into(), art: Arc::new(wide) }); + let pieces = pieces_of(&d, d.symbols.iter().find(|s| s.name == "WideShaded").unwrap()).unwrap(); + let area: f64 = pieces.iter().map(|p| p.path.to_bezpath().area().abs()).sum(); + assert!((area - 30_000.0).abs() < 1e-3, "{area}"); +} diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 76b4e1a715..347968b2c5 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -208,7 +208,7 @@ pub fn specs() -> Vec { "Symbol Options…", [], None, - "{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic, nineSlice?: bool, nineSliceGuides?: array, type?: static|dynamic} rename a symbol and/or set Symbol Options › Registration, Export Type, 9-slice guides (nineSliceGuides [left, top, right, bottom] fractions 0..1) and Symbol Type (a dynamic symbol's instances may change the appearance of objects in its art and stay linked; making it static drops those changes); default name: current → {name, registration, exportType, nineSlice, type}", + "{name?, newName?, registration?: 0..8, exportType?: movieClip|graphic, nineSlice?: bool, nineSliceGuides?: array, type?: static|dynamic} rename a symbol (its instances and the graphics mapped onto 3D objects follow the new name) and/or set Symbol Options › Registration, Export Type, 9-slice guides (nineSliceGuides [left, top, right, bottom] fractions 0..1) and Symbol Type (a dynamic symbol's instances may change the appearance of objects in its art and stay linked; making it static drops those changes); default name: current → {name, registration, exportType, nineSlice, type}", has_doc, symbol_options ), @@ -326,7 +326,7 @@ pub fn specs() -> Vec { "Select All Unused", [], None, - "{} → {names: [the symbols nothing places, in panel order]} (the Symbols panel selects them): instances in the art (opacity masks included), graph designs, pattern tiles (with the tiles that pattern editing's Cancel puts back) and brush art count, and so do instances in the art of a symbol that is used", + "{} → {names: [the symbols nothing places, in panel order]} (the Symbols panel selects them): instances in the art (opacity masks included), graphics mapped onto 3D objects (threeD.mapGraphic), graph designs, pattern tiles (with the tiles that pattern editing's Cancel puts back) and brush art count, and so do instances in the art of a symbol that is used", has_doc, symbol_unused ), diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index b789f600b4..edd2335f6a 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -115,6 +115,8 @@ fn art_symbols(n: &Node, out: &mut Vec) { if let NodeKind::SymbolInstance { symbol, .. } = &m.kind { out.push(symbol.clone()); } + // A graphic on a 3D object draws its symbol's art too. + out.extend(vectorcraft_render::effects::graphic_symbols(m)); }); } @@ -129,6 +131,8 @@ pub(super) fn symbol_unused(s: &mut Session, _: &Value) -> Result { let patterns = d.patterns.iter().chain(d.pattern_edit.iter().filter_map(|pe| pe.original.as_ref())); let trees = d.layers.iter().map(|l| &**l).chain(d.graph_designs.iter().map(|g| &*g.art)); trees.chain(patterns.flat_map(|p| p.art.iter().map(|n| &**n))).chain(lib.iter().flat_map(|b| b.art())).for_each(|n| art_symbols(n, &mut todo)); + // A graphic in a graphic style draws its symbol's art wherever the style is applied. + todo.extend(d.graphic_styles.iter().flat_map(|g| vectorcraft_render::effects::appearance_graphic_symbols(&g.appearance))); let mut used = std::collections::BTreeSet::new(); while let Some(name) = todo.pop() { if !used.contains(&name) { @@ -290,6 +294,33 @@ pub(super) fn symbol_options(s: &mut Session, p: &Value) -> Result { *symbol = n.clone(); } } + // The graphics mapped onto 3D objects follow the new name: in the layers, the art of + // symbols, patterns, graph designs and brushes, their opacity masks, and graphic styles. + let patterns = d.patterns.iter_mut().chain(d.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())); + let trees = d + .layers + .iter_mut() + .chain(d.symbols.iter_mut().map(|sym| &mut sym.art)) + .chain(d.graph_designs.iter_mut().map(|g| &mut g.art)) + .chain(patterns.flat_map(|p| p.art.iter_mut())); + for art in trees.filter(|art| vectorcraft_render::effects::names_graphic_symbol(art, &name)) { + vectorcraft_render::effects::rename_graphic_symbol_in(Arc::make_mut(art), &name, n); + } + let styled = |g: &vectorcraft_doc::GraphicStyle| vectorcraft_render::effects::appearance_graphic_symbols(&g.appearance).contains(&name); + if d.graphic_styles.iter().any(styled) { + for style in d.graphic_styles.iter_mut() { + vectorcraft_render::effects::rename_appearance_graphic_symbol(&mut style.appearance, &name, n); + } + } + // The brush library is stored again only when a brush's art changed. + let mut lib = brush::library(d); + let mut brushes = false; + for art in lib.iter_mut().flat_map(|b| b.art_mut()) { + brushes |= vectorcraft_render::effects::rename_graphic_symbol_in(art, &name, n); + } + if brushes { + brush::store(d, &lib); + } rename_natural_size(d, &name, n); d.rename_symbol_registration(&name, n); d.rename_symbol_export_type(&name, n); diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index b1377d2fef..243c69f671 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -86,6 +86,10 @@ fn paint_refs(p: &Paint, f: Visit) { } fn appearance_refs(a: &Appearance, f: Visit) { + // The graphics mapped onto the faces of its 3D effects name symbols. + for s in vectorcraft_render::effects::appearance_graphic_symbols(a) { + f(Ref::Name(Res::Symbol, &s)); + } for it in &a.items { paint_refs(it.paint(), f); if let AppearanceItem::Stroke(l) = it @@ -620,6 +624,17 @@ impl Relink { } } } + // A 3D effect's mapped graphics follow their symbols' names in the document. Each name is + // looked up once, so renames that chain (A to B, B to C) do not apply twice. + let items = a.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut()); + for e in a.effects.iter_mut().chain(items).filter(|e| vectorcraft_render::effects::takes_graphics(&e.id)) { + let list = e.params.get_mut(vectorcraft_render::effects::GRAPHICS).and_then(Value::as_array_mut); + for g in list.into_iter().flatten() { + if let Some(Value::String(s)) = g.get_mut("symbol") { + self.name(Res::Symbol, s); + } + } + } for it in &mut a.items { self.paint(it.paint_mut()); if let AppearanceItem::Stroke(l) = it diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index ac79d0acc7..942b5a0087 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -629,14 +629,15 @@ fn info(s: &mut Session, p: &Value) -> Result { } let font_names: Vec = fonts.iter().map(super::fonts::font_label).collect(); // Each brush the art uses with its type, found by name in the Brushes panel as the renderer finds it (the last of two - // brushes with one name); a brush the panel does not hold draws as a plain stroke and has no type. + // brushes with one name); a brush the panel does not hold draws as a plain stroke and has no type. A row names the type + // as the New Brush dialog does (`Art Brush`), and the panel translates these values when it shows them. let kinds: HashMap<&str, &vectorcraft_brush::BrushKind> = library.iter().map(|b| (b.name.as_str(), &b.kind)).collect(); let brush_entries: Vec = brushes.iter().map(|name| json!({ "name": name, "type": kinds.get(name.as_str()).map(|k| k.type_id()) })).collect(); let brush_rows: Vec<(String, String)> = brushes .iter() .map(|name| { - (name.clone(), kinds.get(name.as_str()).map_or_else(|| "missing from the Brushes panel".to_string(), |k| format!("{} brush", k.label()))) + (name.clone(), kinds.get(name.as_str()).map_or_else(|| "missing from the Brushes panel".to_string(), |k| format!("{} Brush", k.label()))) }) .collect(); let mut out = json!({ @@ -805,10 +806,10 @@ mod tests { let i = brushes(&mut s, json!({"category": "brushes"})); assert_eq!(i["brushNames"], json!(["Charcoal", "Dots"])); assert_eq!(i["brushes"], json!([{"name": "Charcoal", "type": "art"}, {"name": "Dots", "type": "scatter"}])); - assert_eq!(i["sections"][0]["rows"], json!([["Charcoal", "Art brush"], ["Dots", "Scatter brush"]])); + assert_eq!(i["sections"][0]["rows"], json!([["Charcoal", "Art Brush"], ["Dots", "Scatter Brush"]])); assert!(panel.contains(&json!("3 pt. Round")) && i["panelBrushNames"] == json!(panel)); let text = brushes(&mut s, json!({"category": "brushes", "format": "text"}))["text"].as_str().unwrap().to_string(); - assert!(text.contains("\nBRUSHES\nCharcoal: Art brush\nDots: Scatter brush\n") && !text.contains("3 pt. Round"), "{text}"); + assert!(text.contains("\nBRUSHES\nCharcoal: Art Brush\nDots: Scatter Brush\n") && !text.contains("3 pt. Round"), "{text}"); assert!(!text.contains("OBJECTS"), "{text}"); // Selection Only: the brushes the selected objects' strokes use. let selected = |s: &mut Session| brushes(s, json!({"selectionOnly": true, "category": "brushes"})); @@ -818,10 +819,10 @@ mod tests { let i = selected(&mut s); assert_eq!( (i["brushes"].clone(), i["sections"][0]["rows"].clone()), - (json!([{"name": "Dots", "type": "scatter"}]), json!([["Dots", "Scatter brush"]])) + (json!([{"name": "Dots", "type": "scatter"}]), json!([["Dots", "Scatter Brush"]])) ); let text = brushes(&mut s, json!({"selectionOnly": true, "category": "brushes", "format": "text"}))["text"].as_str().unwrap().to_string(); - assert!(text.contains("\nBRUSHES\nDots: Scatter brush\n") && !text.contains("Charcoal"), "{text}"); + assert!(text.contains("\nBRUSHES\nDots: Scatter Brush\n") && !text.contains("Charcoal"), "{text}"); // A stroke without a brush lists none. let plain = s.execute("shape.rectangle", &json!({"x": 60, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); s.execute("select.set", &json!({"ids": [plain]})).unwrap(); diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index b570ce332a..8040b0016c 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -34,8 +34,15 @@ fn svg_filter_name(s: &Session, xml: &str, cmd: &str) -> Result { Ok(name) } -/// The effect commands of the Effect menu and the Appearance panel. +/// The effect commands of the Effect menu and the Appearance panel, and the 3D and Materials +/// panel's Graphics. pub fn specs() -> Vec { + let mut specs = effect_specs(); + specs.extend(graphics::specs()); + specs +} + +fn effect_specs() -> Vec { vec![ cmd!( "effect.apply", @@ -454,6 +461,9 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { let on = at.and_then(|at| at.effects_item(&n.appearance, C).ok()).flatten(); let on = on.and_then(|i| Some((i, n.appearance.items.get(i)?.effects().len()))); effects::validate_three_d_at_in(Some(d), n, id, &effect.params, on).map_err(|e| bad(C, e))?; + if let Some(name) = effects::missing_symbol(d, &effect.params, None) { + return Err(bad(C, format!("no symbol named `{name}`"))); + } if id == effects::REVOLVE && effects::has_revolve(n) { return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead.")); } @@ -758,6 +768,9 @@ 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); } + if let Some(name) = effects::missing_symbol(d, &prospective, Some(&e.params)) { + return Err(bad(C, format!("no symbol named `{name}`"))); + } if visible != Some(false) { effects::validate_three_d_at_in(Some(d), n, to, &prospective, target.map(|item| (item, index))).map_err(|e| bad(C, e))?; } else { @@ -1201,6 +1214,8 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { Ok(out) } +mod graphics; + #[cfg(test)] mod lighting_presets_tests; diff --git a/crates/engine/src/cmd/effectcmd/graphics.rs b/crates/engine/src/cmd/effectcmd/graphics.rs new file mode 100644 index 0000000000..1801c83492 --- /dev/null +++ b/crates/engine/src/cmd/effectcmd/graphics.rs @@ -0,0 +1,153 @@ +//! Window › 3D and Materials › Materials › Graphics: art kept as a document symbol (Add as Single +//! Graphic) and mapped onto the planar faces of 3D objects (Extrude & Bevel's caps, a plane). The +//! graphics an object shows are the `graphics` parameter of its 3D effect, which `effect.setParams` +//! also edits. + +use serde_json::{Map, Value, json}; +use vectorcraft_doc::{Effect, Node}; +use vectorcraft_render::effects; + +use super::super::appearance::{appearance_targets, index_param, item_target}; +use super::super::brushsym::{normalize, selection_art, store_symbol}; +use super::super::{CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, has_doc, has_selection, str_param}; +use super::{effect_targets, ids_json}; + +pub(super) fn specs() -> Vec { + vec![ + cmd!( + "threeD.addGraphic", + "Add as Single Graphic", + [], + None, + "{name?: string (default Graphic; made unique), ids?|id?} keep the selection's art as a new document symbol, listed among the 3D and Materials panel's Graphics; the selection stays as it is → {name}", + has_selection, + add_graphic + ), + cmd!( + "threeD.mapGraphic", + "Map Graphic", + [], + None, + "{symbol: name of a document symbol, face?: front|back (front; Plane has only a front; the back reads left to right as seen from behind), x?: pt, y?: pt (0, 0: the offset of the graphic's center from the center of the object's bounds, in the face's own plane), scale?: % (1..10000; default 100, or less so the graphic fits inside the object), rotation?: degrees (-360..360, 0; counterclockwise), index?: int (the 3D effect's position; default: each object's first 3D effect, its own before its fills' and strokes'), item?: appearance item index|null (refused without index; as effect.setParams), ids?|id?} add a graphic to the 3D effect of each selected object, drawn on that face turned, foreshortened and shaded with it and clipped to it; every object's 3D effect must be Extrude & Bevel (on a solid cap) or Rotate (the Plane type); one undo step → {ids, graphic}", + has_doc, + map_graphic + ), + ] +} + +/// `threeD.addGraphic`: stores the selection's art as a symbol without replacing it with an +/// instance. +fn add_graphic(s: &mut Session, p: &Value) -> Result { + const C: &str = "threeD.addGraphic"; + if let Some(key) = p.as_object().and_then(|m| m.keys().find(|k| !["name", "ids", "id"].contains(&k.as_str()))) { + return Err(bad(C, format!("unknown parameter `{key}`"))); + } + if p.get("name").is_some_and(|v| v.as_str().is_none_or(|n| n.trim().is_empty())) { + return Err(bad(C, "`name` must be a name that is not empty")); + } + let mut art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?; + crate::cmd::threads::keep_shown_text(&s.doc()?.doc, &mut art); + let (art, size, _) = normalize(art)?; + let name = s.doc()?.doc.unique_symbol_name(str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).unwrap_or("Graphic")); + s.edit("Add as Single Graphic", |d, _| { + store_symbol(d, &art, &name, size); + Ok(()) + })?; + Ok(json!({ "name": name })) +} + +/// The 3D effect of `n` that a graphic goes on when none is named: its own first, then its fills' +/// and strokes', as the 3D and Materials panel finds it. +fn first_three_d(n: &Node) -> Option<(Option, usize)> { + let at = |fx: &[Effect]| fx.iter().position(|e| effects::is_three_d(&e.id)); + at(&n.appearance.effects) + .map(|i| (None, i)) + .or_else(|| n.appearance.items.iter().enumerate().find_map(|(k, item)| at(item.effects()).map(|i| (Some(k), i)))) +} + +/// `threeD.mapGraphic`: appends a graphic to the 3D effect of each target, after checking that every +/// target takes it. +fn map_graphic(s: &mut Session, p: &Value) -> Result { + const C: &str = "threeD.mapGraphic"; + const KEYS: [&str; 10] = ["symbol", "face", "x", "y", "scale", "rotation", "index", "item", "ids", "id"]; + if let Some(key) = p.as_object().and_then(|m| m.keys().find(|k| !KEYS.contains(&k.as_str()))) { + return Err(bad(C, format!("unknown parameter `{key}`"))); + } + // An item names where the effect is in that item's list, so it takes an index too. + if p.get("item").is_some() && p.get("index").is_none() { + return Err(bad(C, "`item` needs `index`")); + } + // The graphic's own settings, checked as the effect checks them. + let mut given = Map::new(); + for key in ["symbol", "face", "x", "y", "scale", "rotation"] { + if let Some(v) = p.get(key) { + given.insert(key.into(), v.clone()); + } + } + let base = effects::MappedGraphic::from_value(&Value::Object(given)).map_err(|e| bad(C, e))?; + let d = &s.doc()?.doc; + if !d.symbols.iter().any(|sym| sym.name == base.symbol) { + return Err(bad(C, format!("no symbol named `{}`", base.symbol))); + } + let targets: Vec<(NodeId, Option, usize)> = if p.get("index").is_some() { + let index = index_param(p, "index", C)?; + let item = item_target(s, p, C)?; + effect_targets(s, p, item, index, C)? + } else { + let mut out = vec![]; + for id in appearance_targets(s, p)? { + let (item, index) = d.node(id).and_then(first_three_d).ok_or_else(|| bad(C, format!("object {} needs a 3D effect", id.0)))?; + out.push((id, item, index)); + } + out + }; + if targets.is_empty() { + return Err(bad(C, "select an object with a 3D effect")); + } + let mut edits = Vec::with_capacity(targets.len()); + for &(id, item, index) in &targets { + let n = d.node(id).ok_or(EngineError::NoNode(id))?; + let Some(effect) = n.appearance.effects_at(item).and_then(|fx| fx.get(index)).filter(|e| effects::is_three_d(&e.id)) else { + return Err(bad(C, format!("object {} needs a 3D effect at index {index}", id.0))); + }; + if !effects::takes_graphics(&effect.id) { + return Err(bad(C, format!("object {}: Graphics map onto planar faces: use the Extrude or Plane object type", id.0))); + } + // A hollow Extrude & Bevel keeps the graphics it has, to show when Cap is turned on again, + // but has no cap to show a new one on. + let merged = effects::merged_params(&effect.id, &effect.params); + let extrude = (effect.id == effects::EXTRUDE).then(|| effects::extrude_options(&merged)); + if extrude.is_some_and(|o| o.depth > 0.0 && !o.cap) { + return Err(bad(C, format!("object {}: a hollow Extrude & Bevel has no caps: turn on Cap to map a graphic", id.0))); + } + // With a depth of 0 the art is a plane, which has only a front. + if base.face == effects::PlanarFace::Back && extrude.is_some_and(|o| o.depth <= 0.0) { + return Err(bad(C, format!("object {}: with a depth of 0 the object has one face: map its graphics onto the front", id.0))); + } + let mut graphic = base.clone(); + if p.get("scale").is_none() + && let (Some(frame), Some(art)) = (effects::graphic_frame_at(Some(d), n, item, index), effects::graphic_bounds(d, &graphic.symbol)) + { + graphic.scale = frame.fitting_scale(art); + } + let params = effects::with_graphic(&effects::merged_params(&effect.id, &effect.params), &graphic); + effects::validate_three_d_at_in(Some(d), n, &effect.id, ¶ms, item.map(|i| (i, index))).map_err(|e| bad(C, e))?; + edits.push((id, item, index, graphic)); + } + let first = edits.first().map(|(.., g)| g.to_value()); + let ids = s.edit("Map Graphic", |d, _| { + let mut done = vec![]; + for (id, item, index, graphic) in &edits { + let Some(e) = d.node_mut(*id).and_then(|n| n.appearance.effects_mut(*item)).and_then(|fx| fx.get_mut(*index)) else { continue }; + let current = effects::upgrade_params(&e.id, &e.params); + e.params = effects::with_graphic(¤t, graphic); + done.push(*id); + } + Ok(done) + })?; + Ok(json!({ "ids": ids_json(&ids), "graphic": first })) +} + +#[cfg(test)] +#[path = "graphics_tests.rs"] +mod tests; diff --git a/crates/engine/src/cmd/effectcmd/graphics_tests.rs b/crates/engine/src/cmd/effectcmd/graphics_tests.rs new file mode 100644 index 0000000000..125ba82514 --- /dev/null +++ b/crates/engine/src/cmd/effectcmd/graphics_tests.rs @@ -0,0 +1,289 @@ +#![allow(clippy::unwrap_used, clippy::panic)] +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; + +use crate::Session; + +/// A 100 × 100 square with `effect`, and a 10 × 20 red rectangle beside it kept as graphic "Mark". +fn setup(effect: &str) -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":400,"height":400})).unwrap(); + let mark = s.execute("path.create", &json!({"d":"M300 300L310 300L310 320L300 320Z"})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"color":"#ff0000"})).unwrap(); + let made = s.execute("threeD.addGraphic", &json!({"name": "Mark", "ids": [mark]})).unwrap(); + assert_eq!(made["name"], "Mark"); + let id = NodeId(s.execute("path.create", &json!({"d":"M100 100L200 100L200 200L100 200Z"})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"color":"#338ad6"})).unwrap(); + s.execute("effect.apply", &json!({"effect":effect,"item":null,"params":{"rotationX":0.0,"rotationY":0.0,"rotationZ":0.0}})).unwrap(); + (s, id) +} + +fn graphics(s: &Session, id: NodeId) -> Value { + s.doc().unwrap().doc.node(id).unwrap().appearance.effects[0].params["graphics"].clone() +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +#[test] +fn add_as_single_graphic_keeps_the_art_and_makes_a_symbol() { + let (s, _) = setup("threeD.extrude"); + let d = &s.doc().unwrap().doc; + assert!(d.symbols.iter().any(|sym| sym.name == "Mark")); + // The art stays a plain path: no instance replaced it. + let mut instances = 0; + for l in &d.layers { + l.walk(&mut |n| instances += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::SymbolInstance { .. }))); + } + assert_eq!(instances, 0); + // The 10 × 20 rectangle and its 1 pt stroke. + assert_eq!(vectorcraft_render::effects::graphic_bounds(d, "Mark").map(|r| (r.width(), r.height())), Some((11.0, 21.0))); +} + +#[test] +fn map_graphic_adds_to_the_selected_object_in_one_undo_step_and_undoes() { + let (mut s, id) = setup("threeD.extrude"); + let before = s.doc().unwrap().doc.clone(); + let history = undo_len(&s); + let r = s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "x": 5.0, "rotation": 30.0})).unwrap(); + assert_eq!(r["ids"], json!([id.0])); + assert_eq!(graphics(&s, id), json!([{"symbol": "Mark", "face": "front", "x": 5.0, "y": 0.0, "scale": 100.0, "rotation": 30.0}])); + assert_eq!(undo_len(&s), history + 1); + s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "face": "back"})).unwrap(); + assert_eq!(graphics(&s, id).as_array().unwrap().len(), 2); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.layers, before.layers); +} + +#[test] +fn a_large_graphic_starts_inside_its_object() { + let (mut s, id) = setup("threeD.rotate"); + let big = s.execute("path.create", &json!({"d":"M0 0L400 0L400 200L0 200Z"})).unwrap()["id"].clone(); + s.execute("threeD.addGraphic", &json!({"name": "Big", "ids": [big]})).unwrap(); + s.execute("threeD.mapGraphic", &json!({"symbol": "Big", "ids": [id.0]})).unwrap(); + // 401 pt wide with its stroke, in a 100 pt square. + let scale = graphics(&s, id)[0]["scale"].as_f64().unwrap(); + assert!((scale - 100.0 * 100.0 / 401.0).abs() < 1e-9, "{scale}"); +} + +#[test] +fn map_graphic_refuses_what_cannot_take_it_and_changes_nothing() { + let (mut s, id) = setup("threeD.inflate"); + let history = undo_len(&s); + let e = s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "ids": [id.0]})).unwrap_err().to_string(); + assert!(e.contains("Extrude or Plane"), "{e}"); + let (mut s, id) = setup("threeD.rotate"); + for (p, says) in [ + (json!({"symbol": "Gone"}), "no symbol named"), + (json!({"face": "front"}), "needs `symbol`"), + (json!({"symbol": "Mark", "face": "back"}), "one face"), + (json!({"symbol": "Mark", "scale": 0.0}), "scale"), + (json!({"symbol": "Mark", "tile": true}), "`tile`"), + (json!({"symbol": "Mark", "item": 0}), "`item` needs `index`"), + (json!({"symbol": "Mark", "item": null}), "`item` needs `index`"), + ] { + let mut p = p; + p["ids"] = json!([id.0]); + let e = s.execute("threeD.mapGraphic", &p).unwrap_err().to_string(); + assert!(e.contains(says), "{p}: {e}"); + } + assert_eq!(undo_len(&s), history); + // An object without a 3D effect. + let plain = s.execute("path.create", &json!({"d":"M0 0L10 0L10 10Z"})).unwrap()["id"].clone(); + let e = s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "ids": [plain]})).unwrap_err().to_string(); + assert!(e.contains("3D effect"), "{e}"); + // A hollow Extrude & Bevel has no cap to show a new graphic on. + let (mut s, id) = setup("threeD.extrude"); + s.execute("effect.setParams", &json!({"index": 0, "item": null, "params": {"cap": false}})).unwrap(); + let history = undo_len(&s); + let e = s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "ids": [id.0]})).unwrap_err().to_string(); + assert!(e.contains("turn on Cap"), "{e}"); + assert_eq!(undo_len(&s), history); + // With a depth of 0 there is no back. + s.execute("effect.setParams", &json!({"index": 0, "item": null, "params": {"cap": true, "depth": 0.0}})).unwrap(); + let history = undo_len(&s); + let e = s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "face": "back", "ids": [id.0]})).unwrap_err().to_string(); + assert!(e.contains("one face"), "{e}"); + assert_eq!(undo_len(&s), history); + assert!(s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "ids": [id.0]})).is_ok()); +} + +#[test] +fn set_params_edits_graphics_and_checks_them() { + let (mut s, id) = setup("threeD.extrude"); + s.execute("threeD.mapGraphic", &json!({"symbol": "Mark"})).unwrap(); + let moved = json!([{"symbol": "Mark", "face": "front", "x": -12.0, "y": 8.0, "scale": 150.0, "rotation": 0.0}]); + s.execute("effect.setParams", &json!({"ids": [id.0], "index": 0, "item": null, "params": {"graphics": moved}})).unwrap(); + assert_eq!(graphics(&s, id), moved); + let bad = json!({"ids": [id.0], "index": 0, "item": null, "params": {"graphics": [{"symbol": "Mark", "face": "top"}]}}); + assert!(s.execute("effect.setParams", &bad).is_err()); + let null = json!({"ids": [id.0], "index": 0, "item": null, "params": {"graphics": null}}); + assert!(s.execute("effect.setParams", &null).is_err()); + assert_eq!(graphics(&s, id), moved); + // Extrude to Plane keeps the graphics; to Inflate drops them. + s.execute("effect.setParams", &json!({"ids": [id.0], "index": 0, "item": null, "threeDType": "plane"})).unwrap(); + assert_eq!(graphics(&s, id), moved); + s.execute("effect.setParams", &json!({"ids": [id.0], "index": 0, "item": null, "threeDType": "inflate"})).unwrap(); + assert_eq!(graphics(&s, id), Value::Null); +} + +#[test] +fn effect_commands_refuse_an_unknown_symbol_but_keep_a_deleted_ones_graphic() { + let (mut s, id) = setup("threeD.extrude"); + let unknown = json!([{"symbol": "Nowhere"}]); + let history = undo_len(&s); + assert!(s.execute("effect.setParams", &json!({"ids": [id.0], "index": 0, "item": null, "params": {"graphics": unknown}})).is_err()); + s.execute("path.create", &json!({"d":"M0 0L50 0L50 50L0 50Z"})).unwrap(); + let created = undo_len(&s); + assert!(s.execute("effect.apply", &json!({"effect": "threeD.extrude", "item": null, "params": {"graphics": unknown}})).is_err()); + assert_eq!((history + 1, undo_len(&s)), (created, created)); + // A graphic whose symbol was deleted stays while the effect's other settings change. + s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "ids": [id.0]})).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).symbols.retain(|sym| sym.name != "Mark"); + s.execute("effect.setParams", &json!({"ids": [id.0], "index": 0, "item": null, "params": {"depth": 30.0}})).unwrap(); + assert_eq!(graphics(&s, id)[0]["symbol"], "Mark"); +} + +#[test] +fn a_symbol_a_graphic_uses_is_used_and_follows_a_rename() { + let (mut s, id) = setup("threeD.extrude"); + s.execute("threeD.mapGraphic", &json!({"symbol": "Mark"})).unwrap(); + let unused = s.execute("symbol.unused", &json!({})).unwrap(); + assert!(!unused["names"].as_array().unwrap().iter().any(|n| n == "Mark"), "{unused}"); + // A 3D object with the graphic kept in another symbol's art follows the rename too. + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("edit.paste", &json!({})).unwrap(); + s.execute("symbol.new", &json!({"name": "Holder"})).unwrap(); + s.execute("symbol.options", &json!({"name": "Mark", "newName": "Label"})).unwrap(); + assert_eq!(graphics(&s, id)[0]["symbol"], "Label"); + let d = &s.doc().unwrap().doc; + let holder = d.symbols.iter().find(|sym| sym.name == "Holder").unwrap(); + let mut held = vec![]; + holder.art.walk(&mut |n| held.extend(vectorcraft_render::effects::graphic_symbols(n))); + assert_eq!(held, vec!["Label".to_string()]); +} + +#[test] +fn a_rename_reaches_graphics_in_graph_designs_and_opacity_masks() { + let (mut s, id) = setup("threeD.extrude"); + s.execute("threeD.mapGraphic", &json!({"symbol": "Mark"})).unwrap(); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let held = d.node(id).unwrap().clone(); + d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Bars".into(), art: std::sync::Arc::new(held.clone()) }); + d.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Boxed", (*held.appearance).clone())); + let mut lib = vectorcraft_brush::library(d); + let scatter = lib.iter_mut().find(|b| !b.art().is_empty()).unwrap(); + *scatter.art_mut()[0] = held.clone(); + vectorcraft_brush::store(d, &lib); + d.node_mut(id).unwrap().extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(held, true))); + s.execute("symbol.options", &json!({"name": "Mark", "newName": "Label"})).unwrap(); + let d = &s.doc().unwrap().doc; + let style = d.graphic_styles.iter().find(|g| g.name == "Boxed").unwrap(); + assert_eq!(vectorcraft_render::effects::appearance_graphic_symbols(&style.appearance), vec!["Label".to_string()]); + let brush: Vec = + vectorcraft_brush::library(d).iter().flat_map(|b| b.art()).flat_map(vectorcraft_render::effects::graphic_symbols).collect(); + assert_eq!(brush, vec!["Label".to_string()]); + let names = |n: &vectorcraft_doc::Node| { + let mut out = vec![]; + n.walk(&mut |c| out.extend(vectorcraft_render::effects::graphic_symbols(c))); + out + }; + assert_eq!(names(&d.graph_designs[0].art), vec!["Label".to_string()]); + let mask = d.node(id).unwrap().extras().mask.as_deref().map(|m| names(&m.art)); + assert_eq!(mask, Some(vec!["Label".to_string()])); +} + +#[test] +fn pasted_into_another_document_a_graphic_brings_its_symbol() { + let (mut s, id) = setup("threeD.extrude"); + s.execute("threeD.mapGraphic", &json!({"symbol": "Mark"})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + // The symbol the pasted object's graphic names, and the size of that symbol's art. + let pasted = |s: &mut Session| { + s.execute("edit.paste", &json!({})).unwrap(); + let st = s.doc().unwrap(); + let n = st.doc.node(st.selection.objects[0]).unwrap(); + let name = n.appearance.effects[0].params["graphics"][0]["symbol"].as_str().unwrap().to_string(); + let size = vectorcraft_render::effects::graphic_bounds(&st.doc, &name).map(|r| (r.width(), r.height())); + (name, size) + }; + // A document without the symbol gets it. + s.execute("file.new", &json!({"width":400,"height":400})).unwrap(); + assert_eq!(pasted(&mut s), ("Mark".to_string(), Some((11.0, 21.0)))); + // A document whose symbol "Mark" is other art gets the copied symbol under a free name. + s.execute("file.new", &json!({"width":400,"height":400})).unwrap(); + let other = s.execute("path.create", &json!({"d":"M0 0L50 0L50 50L0 50Z"})).unwrap()["id"].clone(); + s.execute("threeD.addGraphic", &json!({"name": "Mark", "ids": [other]})).unwrap(); + let (name, size) = pasted(&mut s); + assert_ne!(name, "Mark"); + assert_eq!(size, Some((11.0, 21.0))); +} + +#[test] +fn a_symbol_only_a_graphic_style_uses_is_used() { + let (mut s, id) = setup("threeD.extrude"); + s.execute("threeD.mapGraphic", &json!({"symbol": "Mark"})).unwrap(); + let appearance = (*s.doc().unwrap().doc.node(id).unwrap().appearance).clone(); + s.execute("effect.setParams", &json!({"index": 0, "item": null, "params": {"graphics": []}})).unwrap(); + let unused = |s: &mut Session| s.execute("symbol.unused", &json!({})).unwrap()["names"].clone(); + assert!(unused(&mut s).as_array().unwrap().iter().any(|n| n == "Mark")); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Boxed", appearance)); + assert!(!unused(&mut s).as_array().unwrap().iter().any(|n| n == "Mark")); +} + +#[test] +fn add_graphic_refuses_bad_parameters_and_changes_nothing() { + let (mut s, _) = setup("threeD.extrude"); + let art = s.execute("path.create", &json!({"d":"M0 0L10 0L10 10Z"})).unwrap()["id"].clone(); + let (symbols, history) = (s.doc().unwrap().doc.symbols.len(), undo_len(&s)); + for p in [ + json!({"name": 5, "ids": [art]}), + json!({"name": null, "ids": [art]}), + json!({"name": "", "ids": [art]}), + json!({"name": " ", "ids": [art]}), + json!({"tile": true, "ids": [art]}), + ] { + assert!(s.execute("threeD.addGraphic", &p).is_err(), "{p}"); + } + assert_eq!((s.doc().unwrap().doc.symbols.len(), undo_len(&s)), (symbols, history)); +} + +#[test] +fn map_graphic_refuses_a_selection_with_an_object_without_a_3d_effect() { + let (mut s, id) = setup("threeD.extrude"); + let plain = s.execute("path.create", &json!({"d":"M0 0L10 0L10 10Z"})).unwrap()["id"].as_u64().unwrap(); + let history = undo_len(&s); + let e = s.execute("threeD.mapGraphic", &json!({"symbol": "Mark", "ids": [id.0, plain]})).unwrap_err().to_string(); + assert!(e.contains(&format!("object {plain} needs a 3D effect")), "{e}"); + assert_eq!(undo_len(&s), history); + assert_eq!(graphics(&s, id).as_array().map_or(0, Vec::len), 0); +} + +#[test] +fn the_graphic_is_drawn_on_the_object() { + let (mut s, id) = setup("threeD.extrude"); + s.execute("threeD.mapGraphic", &json!({"symbol": "Mark"})).unwrap(); + let d = &s.doc().unwrap().doc; + let art = vectorcraft_render::effects::solid_art_in(Some(d), d.node(id).unwrap()).unwrap(); + let mut red = None; + art.walk(&mut |n| { + if let (Some(p), Some(vectorcraft_doc::AppearanceItem::Fill(f))) = (n.path_data(), n.appearance.items.first()) + && f.paint.color().is_some_and(|c| c.to_rgb()[0] > 0.3 && c.to_rgb()[1] < 0.1) + { + red = p.bounds(); + } + }); + // Seen straight on, the 10 × 20 graphic sits at the center of the square. + let r = red.unwrap(); + assert!( + (r.center().x - 150.0).abs() < 1e-6 + && (r.center().y - 150.0).abs() < 1e-6 + && (r.width() - 10.0).abs() < 1e-6 + && (r.height() - 20.0).abs() < 1e-6, + "{r:?}" + ); +} diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index fff7289352..0f5ada29fe 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -627,6 +627,8 @@ pub(super) const ALLOWED: &[&str] = &[ "text.thread.releaseSelection", "text.thread.remove", "text.touchType", + "threeD.addGraphic", + "threeD.mapGraphic", "transparency.disableOpacityMask", "transparency.editOpacityMask", "transparency.enableOpacityMask", diff --git a/crates/pathops/src/offset.rs b/crates/pathops/src/offset.rs index bf533476be..f9579f1bf3 100644 --- a/crates/pathops/src/offset.rs +++ b/crates/pathops/src/offset.rs @@ -126,21 +126,49 @@ fn path_is_finite(path: &PathData) -> bool { /// Fallible [`offset_path`] that returns an error when the input is non-finite or exceeds /// the complexity bound ([`MAX_OFFSET_SEGMENT_PAIRS`]), or when planar arrangement fails. pub fn try_offset_path(path: &PathData, delta: f64, join: Join, miter_limit: f64) -> Result { - let src = std::cell::LazyCell::new(|| SegmentTree::new(boxed_segments(&path.to_bezpath()))); - offset_with_check(path, delta, join, miter_limit, |p, r| any_within(&src, p, r)) + offset_with_check(path, delta, join, miter_limit, |part| { + let bp = part.to_bezpath(); + let src = std::cell::LazyCell::new(move || SegmentTree::new(boxed_segments(&bp))); + move |p, r| any_within(&src, p, r) + }) } -/// [`try_offset_path`] with the test of whether some segment of `path` lies closer than a radius to a point. -fn offset_with_check( +/// [`try_offset_path`] with `within_of(part)`, the test of whether some segment of `part` lies +/// closer than a radius to a point. +fn offset_with_check bool>( + path: &PathData, + delta: f64, + join: Join, + miter_limit: f64, + within_of: impl Fn(&PathData) -> W, +) -> Result { + if !delta.is_finite() || !path_is_finite(path) { + return Err(PathOpsError::NonFinite); + } + // An inset keeps only faces inside the closed fill, so it would drop the outline of every + // open subpath (#6427). Inset the closed subpaths, outline the open ones as a positive offset + // does, and unite the two. + if delta < 0.0 && path.subpaths.iter().any(|s| s.anchors.len() > 1 && !close_if_ends_meet(s).closed) { + let (closed, open): (Vec, Vec) = path.subpaths.iter().cloned().partition(|s| close_if_ends_meet(s).closed); + let (closed, open) = (PathData::new(closed), PathData::new(open)); + let inset = offset_region(&closed, delta, join, miter_limit, within_of(&closed))?; + let outline = offset_region(&open, -delta, join, miter_limit, within_of(&open))?; + if inset.is_empty() { + return Ok(outline); + } + return crate::boolean::try_unite_all(&[(&inset, FillRule::NonZero), (&outline, FillRule::NonZero)]); + } + offset_region(path, delta, join, miter_limit, within_of(path)) +} + +/// [`offset_with_check`] of a finite `path` with the `within` test of its own segments. +fn offset_region( path: &PathData, delta: f64, join: Join, miter_limit: f64, within: impl Fn(kurbo::Point, f64) -> bool, ) -> Result { - if !delta.is_finite() || !path_is_finite(path) { - return Err(PathOpsError::NonFinite); - } if path.is_empty() { return Ok(PathData::default()); } @@ -630,14 +658,17 @@ mod tests { fn offsets_with_the_tree_are_identical_to_offsets_with_the_scan() { let (mut checks, mut hits, mut misses) = (0, 0, 0); for path in offset_inputs() { - let segs = boxed_segments(&path.to_bezpath()); for delta in [0.0, 0.05, 1.0, 5.0, 60.0, -0.05, -1.0, -5.0, -60.0] { for join in [Join::Round, Join::Miter, Join::Bevel] { let found = std::cell::Cell::new((0, 0)); - let by_scan = offset_with_check(&path, delta, join, 4.0, |p, r| { - let v = any_within_by_scan(&segs, p, r); - found.set((found.get().0 + usize::from(v), found.get().1 + usize::from(!v))); - v + let by_scan = offset_with_check(&path, delta, join, 4.0, |part| { + let segs = boxed_segments(&part.to_bezpath()); + let found = &found; + move |p, r| { + let v = any_within_by_scan(&segs, p, r); + found.set((found.get().0 + usize::from(v), found.get().1 + usize::from(!v))); + v + } }); checks += 1; hits += found.get().0; diff --git a/crates/pathops/tests/it/ops.rs b/crates/pathops/tests/it/ops.rs index 81e7f83ce7..492aaf56f1 100644 --- a/crates/pathops/tests/it/ops.rs +++ b/crates/pathops/tests/it/ops.rs @@ -316,6 +316,24 @@ fn offset_of_an_open_path_whose_ends_meet_is_the_closed_shape_offset() { assert_eq!(o.subpaths.len(), 1); } +#[test] +fn a_negative_offset_outlines_an_open_path_as_a_positive_one_does() { + // Issue #6427 (A4): an inset of an open path was empty. Open subpaths are outlined with a + // stroke of width 2|delta| whichever way the offset goes. + let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0)], false)); + for delta in [2.0, -2.0] { + let o = offset_path(&line, delta, Join::Miter, 4.0); + assert!(close(ar(&o), 400.0, 1e-6), "delta {delta}: {}", ar(&o)); + assert_eq!(o.subpaths.len(), 1, "delta {delta}"); + } + // With a closed shape beside it, the shape is inset and the line still outlined. + let mut both = rect(0.0, 0.0, 20.0, 10.0); + both.subpaths.push(SubPath::polyline(&[Point::new(100.0, 0.0), Point::new(150.0, 0.0)], false)); + let o = offset_path(&both, -2.0, Join::Miter, 4.0); + assert!(close(ar(&o), 16.0 * 6.0 + 50.0 * 4.0, 1e-6), "{}", ar(&o)); + assert_eq!(o.subpaths.len(), 2); +} + #[test] fn outline_stroke_of_line() { let l = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(30.0, 40.0)], false)); diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 2d945c1439..a807cbb8fa 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -8,7 +8,10 @@ mod solid; mod visibility; -pub use solid::{Bevel, BevelShape, Extrude, Facet, Inflate, View, extrude, inflate, inflate_together, plane}; +pub use solid::{ + Bevel, BevelShape, Extrude, FaceMap, Facet, Inflate, PlanarFace, View, extrude, extrude_face_map, inflate, inflate_together, plane, + plane_face_map, +}; pub use visibility::{VisibleFace, visible_faces}; mod shading; pub use shading::{ClassicShading, ClassicSurface, LightGradient, RgbGradient, ShadeGradient}; diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 18c9959b19..bfd3149e6e 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -14,6 +14,7 @@ use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect, Vec use crate::{MAX_FACES, MAX_SOLID_FACES, Revolve, V3, eye_distance, perspective_scale}; mod build; +mod face_map; mod inflate; mod membrane; mod outline; @@ -23,6 +24,7 @@ mod rings; use build::{Camera, cuts, edge_distance, newell, section}; pub(crate) use build::{Face3, extrude_faces, plane_faces}; pub use build::{extrude, plane}; +pub use face_map::{FaceMap, extrude_face_map, plane_face_map}; #[cfg(test)] use inflate::GRID_WORK; #[cfg(test)] @@ -242,6 +244,29 @@ impl Default for Inflate { } } +/// A planar face of a solid that a graphic can be mapped onto: Extrude & Bevel's front cap (the +/// art itself, or a plane's only face) or its back cap. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum PlanarFace { + Front, + Back, +} + +impl PlanarFace { + pub const ALL: [PlanarFace; 2] = [Self::Front, Self::Back]; + + pub fn id(self) -> &'static str { + match self { + Self::Front => "front", + Self::Back => "back", + } + } + + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|f| f.id() == s) + } +} + /// One projected polygon. Holes are contours of their own (nonzero winding). #[derive(Clone, Debug, PartialEq)] pub struct Facet { @@ -263,6 +288,9 @@ pub struct Facet { /// The facet is turned away from the viewer: a closed solid keeps it (seen edge-on, or with /// Draw Hidden Faces) and draws it behind the facets turned toward the viewer. pub away: bool, + /// The planar end of the solid the facet is: Extrude & Bevel's front or back cap, or a plane + /// (its front). `None` for every other facet. + pub end: Option, /// The contours in view space, before projection, for tests that measure depths. #[cfg(test)] view: Vec>, @@ -307,6 +335,8 @@ impl Facet { rgb_gradient: None, outline: face.outline, side: !face.cap, + // A revolved cap is not one of the planar faces graphics map onto. + end: None, away: false, #[cfg(test)] view: vec![], diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index 0b429409c7..bd4030dfc4 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -6,7 +6,7 @@ use super::rings::{ solid_rings_traced, }; use super::{ - Bevel, Extrude, Facet, FillRule, MAX_FACES, MAX_SOLID_FACES, ParamCurve, PathData, Point, Rect, Revolve, V3, View, eye_distance, + Bevel, Extrude, Facet, FillRule, MAX_FACES, MAX_SOLID_FACES, ParamCurve, PathData, PlanarFace, Point, Rect, Revolve, V3, View, eye_distance, perspective_scale, }; use crate::shadow::Projection; @@ -49,6 +49,11 @@ impl Camera { pub(super) fn brightness(&self, n: V3) -> f64 { crate::brightness(&self.view, n, self.light) } + /// The document point the view's center projects to, and the eye's distance in front of it + /// (`None` without perspective). + pub(super) fn center_and_eye(&self) -> (Point, Option) { + (self.origin, self.eye) + } /// The rotation and projection this camera draws with, for a shadow cast from its faces. pub(super) fn projection(&self) -> Projection { Projection { view: self.view, origin: self.origin, eye: self.eye } @@ -150,6 +155,7 @@ impl Builder<'_> { outline: None, side: false, away: behind, + end: None, #[cfg(test)] view, }); @@ -255,6 +261,17 @@ impl Builder<'_> { } } + /// Mark the facet that [`Builder::face`] added, when it added one after `before` facets, as the + /// planar end `end` of the solid. + pub(super) fn end(&mut self, before: usize, end: PlanarFace) { + if self.mesh.is_some() || self.facets.len() <= before { + return; + } + if let Some(f) = self.facets.last_mut() { + f.end = Some(end); + } + } + /// How many faces the builder holds: facets, or faces kept for a mesh. pub(super) fn len(&self) -> usize { self.mesh.as_ref().map_or(self.facets.len(), Vec::len) @@ -301,6 +318,10 @@ impl Placing { self.at_centre.2 + self.x.2 * dx + self.y.2 * dy, ) } + /// The map as `(x, y, c)`: an art point `p` goes to `x · p.x + y · p.y + c` in view space. + pub(super) fn frame(&self) -> (V3, V3, V3) { + (self.x, self.y, self.place(Point::ZERO)) + } } /// The point `w` points to the left of `span`'s curve at `t`, as the ring runs along it (toward @@ -598,11 +619,12 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio if o.cap { // A cap turned toward the viewer covers the walls it overlaps, even those nearer than its // center: twisted walls, and the walls of a fill inside the solid of its stroke. - let caps = [(steps.first(), along.first(), 1.0), (steps.last(), along.last(), -1.0)]; - for (s, along, n) in caps { + let caps = [(steps.first(), along.first(), 1.0, PlanarFace::Front), (steps.last(), along.last(), -1.0, PlanarFace::Back)]; + for (s, along, n, end) in caps { let Some(s) = s else { continue }; let before = b.len(); b.face(rings.iter().map(|r| ring_at(r, s)).collect(), camera.rotate(V3(0.0, 0.0, n)), Order::Last)?; + b.end(before, end); if let Some(along) = along { let traced: Vec<&Ring> = traced.iter().collect(); b.outline(before, |c| outline::face(&traced, c, along, &mut budget)); @@ -630,6 +652,7 @@ pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: V let up = camera.rotate(V3(0.0, 0.0, 1.0)); let n = if camera.facing(V3::default(), up) < 0.0 { V3(-up.0, -up.1, -up.2) } else { up }; b.face(contours, n, Order::Last)?; + b.end(0, PlanarFace::Front); let m = Placing::new(&camera, bounds.center(), Turn::new(bounds, 0.0, 1.0, 0.0), 0.0); let at = |span: &Span, t: f64| camera.project(m.place(span.curve.eval(t))); let map = |p: Point| camera.project(m.place(p)); diff --git a/crates/three-d/src/solid/face_map.rs b/crates/three-d/src/solid/face_map.rs new file mode 100644 index 0000000000..d84dcc5ee6 --- /dev/null +++ b/crates/three-d/src/solid/face_map.rs @@ -0,0 +1,179 @@ +//! Where the points of a planar face's own plane land on the page: the map a graphic drawn on +//! Extrude & Bevel's caps or on a plane goes through, with the same rotation, twist, taper and +//! perspective as the face itself. + +use vectorcraft_geom::{Affine, Point, Rect}; + +use super::build::{Camera, Placing, Turn, check_bounds}; +use super::{Extrude, PlanarFace, V3, View}; + +/// A planar face's map from the art's plane (document coordinates before the 3D effect) to the +/// page: a projective map, affine without perspective. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FaceMap { + /// Rows of the map in homogeneous coordinates: a point `p` goes to `origin + (r0 · q, r1 · q) / + /// (r2 · q)` with `q = (p.x, p.y, 1)`. + rows: [[f64; 3]; 3], + origin: Point, + /// The eye's distance in front of the view's center; `None` without perspective. + eye: Option, + /// The face is turned toward the viewer. A plane, seen from either side, always is. + pub visible: bool, +} + +/// The smallest share of the eye's distance a point of the face may lie in front of the eye at: +/// the same limit [`crate::perspective_scale`] holds parts reaching past the eye to. +const NEAREST: f64 = 1e-3; + +impl FaceMap { + fn new(camera: &Camera, placing: &Placing, visible: bool) -> Self { + let (x, y, c) = placing.frame(); + let (origin, eye) = camera.center_and_eye(); + let rows = match eye { + Some(e) => [[e * x.0, e * y.0, e * c.0], [e * x.1, e * y.1, e * c.1], [-x.2, -y.2, e - c.2]], + None => [[x.0, y.0, c.0], [x.1, y.1, c.1], [0.0, 0.0, 1.0]], + }; + Self { rows, origin, eye, visible } + } + + fn homogeneous(&self, p: Point) -> [f64; 3] { + self.rows.map(|r| r[0] * p.x + r[1] * p.y + r[2]) + } + + /// The homogeneous depth a mapped point must lie beyond: the eye's distance times [`NEAREST`] + /// with perspective, 0 without. + fn nearest(&self) -> f64 { + self.eye.map_or(0.0, |e| e * NEAREST) + } + + /// Where art point `p` lands on the page. `None` when it lies at or behind the eye, or the + /// result is not finite. + pub fn to_page(&self, p: Point) -> Option { + let [u, v, w] = self.homogeneous(p); + if w.is_nan() || w <= self.nearest() { + return None; + } + let q = Point::new(self.origin.x + u / w, self.origin.y + v / w); + (q.x.is_finite() && q.y.is_finite()).then_some(q) + } + + /// The art point that lands on page point `q`. `None` when the face is seen edge-on there, the + /// point lies beyond the face's horizon, or it lies nearer the eye than [`FaceMap::to_page`] + /// maps. + pub fn to_art(&self, q: Point) -> Option { + let (u, v) = (q.x - self.origin.x, q.y - self.origin.y); + let [r0, r1, r2] = self.rows; + let (a, b, c) = (r0[0] - u * r2[0], r0[1] - u * r2[1], -(r0[2] - u * r2[2])); + let (d, e, f) = (r1[0] - v * r2[0], r1[1] - v * r2[1], -(r1[2] - v * r2[2])); + let det = a * e - b * d; + let scale = (a.abs() + b.abs()).max(d.abs() + e.abs()); + if det.is_nan() || det.abs() <= 1e-12 * scale * scale { + return None; + } + let p = Point::new((c * e - b * f) / det, (a * f - c * d) / det); + // A point of the plane behind the eye projects to the same page point as one in front, and + // `to_page` maps no point nearer the eye than its limit. + (p.x.is_finite() && p.y.is_finite() && self.homogeneous(p)[2] > self.nearest()).then_some(p) + } + + /// Does every point of the cubic Bézier curve with control points `c` lie far enough in front + /// of the eye for [`FaceMap::to_page`] to map it? Its control points need not. + pub fn maps_curve(&self, c: [Point; 4]) -> bool { + let [w0, w1, w2, w3] = c.map(|p| self.homogeneous(p)[2]); + let depth = |t: f64| { + let s = 1.0 - t; + s * s * s * w0 + 3.0 * s * s * t * w1 + 3.0 * s * t * t * w2 + t * t * t * w3 + }; + // The depth is least at an end or where its derivative, a t² + b t + k (times 3), is 0. + let (a, b, k) = (w3 - 3.0 * w2 + 3.0 * w1 - w0, 2.0 * (w2 - 2.0 * w1 + w0), w1 - w0); + // A missing root is NaN, which the range check leaves out. + let mut at = [0.0, 1.0, f64::NAN, f64::NAN]; + if a.abs() > 1e-12 { + let root = (b * b - 4.0 * a * k).sqrt(); + at[2] = (-b + root) / (2.0 * a); + at[3] = (-b - root) / (2.0 * a); + } else if b.abs() > 1e-12 { + at[2] = -k / b; + } + let limit = self.nearest(); + at.into_iter().filter(|t| (0.0..=1.0).contains(t)).all(|t| depth(t) > limit) + } + + /// The part of rectangle `r` of the face's plane whose points [`FaceMap::to_page`] maps, kept + /// back from the eye by twice its nearest distance, as a convex polygon: all of `r` without + /// perspective. `None` when no part of `r` maps. + pub fn mapped_part(&self, r: Rect) -> Option> { + let corners = vec![Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]; + let Some(e) = self.eye else { return Some(corners) }; + let [a, b, c] = self.rows[2]; + let limit = 2.0 * e * NEAREST; + let side = |p: Point| a * p.x + b * p.y + c - limit; + // The polygon cut by the line where `side` is 0 (one step of Sutherland and Hodgman's clip). + let mut out = vec![]; + for (k, &p) in corners.iter().enumerate() { + let q = corners[(k + 1) % corners.len()]; + let (sp, sq) = (side(p), side(q)); + if sp >= 0.0 { + out.push(p); + } + if (sp >= 0.0) != (sq >= 0.0) { + out.push(p.lerp(q, sp / (sp - sq))); + } + } + (out.len() >= 3 && out.iter().all(|p| p.x.is_finite() && p.y.is_finite())).then_some(out) + } + + /// Without perspective, the map is affine. + pub fn is_affine(&self) -> bool { + self.eye.is_none() + } + + /// The affine map nearest the face's map around art point `p` (exact without perspective): + /// what a gradient or a stroke on a graphic is drawn with. `None` where [`FaceMap::to_page`] + /// has no point. + pub fn local_affine(&self, p: Point) -> Option { + let at = self.to_page(p)?; + let [u, v, w] = self.homogeneous(p); + let [r0, r1, r2] = self.rows; + // The derivatives of u / w and v / w along x and y. + let d = |r: [f64; 3], n: f64, k: usize| (r[k] * w - n * r2[k]) / (w * w); + let (a, b, c, e) = (d(r0, u, 0), d(r1, v, 0), d(r0, u, 1), d(r1, v, 1)); + let m = Affine::new([a, b, c, e, at.x - a * p.x - c * p.y, at.y - b * p.x - e * p.y]); + m.as_coeffs().iter().all(|v| v.is_finite()).then_some(m) + } +} + +/// The map of Extrude & Bevel's cap `face` for a solid built with `bounds`, `options` and `view`, +/// as [`super::extrude`] builds it. With a depth of 0 the art is a plane, whose only face is the +/// front. `None` for a hollow solid (no caps), for the back of a plane, and for invalid bounds. +pub fn extrude_face_map(bounds: Rect, options: Extrude, view: View, face: PlanarFace) -> Option { + check_bounds(bounds).ok()?; + let o = options.sanitized(); + if o.depth <= 0.0 { + return (face == PlanarFace::Front).then(|| plane_face_map(bounds, view)).flatten(); + } + if !o.cap { + return None; + } + let camera = Camera::new(view, bounds, o.depth); + let (t, side) = match face { + PlanarFace::Front => (0.0, 1.0), + PlanarFace::Back => (1.0, -1.0), + }; + let placing = Placing::new(&camera, bounds.center(), Turn::new(bounds, o.twist, o.taper, t), o.depth / 2.0 - o.depth * t); + let at = placing.place(bounds.center()); + let visible = camera.facing(at, camera.rotate(V3(0.0, 0.0, side))) > 0.0; + Some(FaceMap::new(&camera, &placing, visible)) +} + +/// The map of the plane [`super::plane`] turns art with `bounds` into, with `view`. +pub fn plane_face_map(bounds: Rect, view: View) -> Option { + check_bounds(bounds).ok()?; + let camera = Camera::new(view, bounds, 0.0); + let placing = Placing::new(&camera, bounds.center(), Turn::new(bounds, 0.0, 1.0, 0.0), 0.0); + Some(FaceMap::new(&camera, &placing, true)) +} + +#[cfg(test)] +#[path = "tests_face_map.rs"] +mod tests; diff --git a/crates/three-d/src/solid/tests_face_map.rs b/crates/three-d/src/solid/tests_face_map.rs new file mode 100644 index 0000000000..31644beda5 --- /dev/null +++ b/crates/three-d/src/solid/tests_face_map.rs @@ -0,0 +1,160 @@ +use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect}; + +use super::super::{Extrude, Facet, PlanarFace, View, extrude, plane}; +use super::{FaceMap, NEAREST, extrude_face_map, plane_face_map}; + +fn square() -> (PathData, Rect) { + let mut b = BezPath::new(); + b.move_to((10.0, 20.0)); + b.line_to((70.0, 20.0)); + b.line_to((70.0, 60.0)); + b.line_to((10.0, 60.0)); + b.close_path(); + let p = PathData::from_bezpath(&b); + let r = p.bounds().unwrap(); + (p, r) +} + +fn turned(perspective: f64) -> View { + View { rotation_x: -30.0, rotation_y: 40.0, rotation_z: 10.0, perspective, ..View::default() } +} + +fn corners(r: Rect) -> [Point; 4] { + [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)] +} + +/// Every corner of `face` is where the map takes one of the art's corners. +fn lands_on(face: &Facet, map: &FaceMap, art: [Point; 4]) { + for c in face.contours.iter().flatten() { + let near = art.iter().filter_map(|p| map.to_page(*p)).map(|q| q.distance(*c)).fold(f64::INFINITY, f64::min); + assert!(near < 1e-6, "facet corner {c:?} is {near} from every mapped corner"); + } +} + +fn end(facets: &[Facet], end: PlanarFace) -> Option<&Facet> { + facets.iter().find(|f| f.end == Some(end)) +} + +#[test] +fn the_front_cap_is_where_the_map_takes_the_art() { + let (path, bounds) = square(); + for perspective in [0.0, 60.0, 140.0] { + let view = turned(perspective); + let facets = extrude(&path, FillRule::NonZero, bounds, Extrude::default(), view).unwrap(); + let map = extrude_face_map(bounds, Extrude::default(), view, PlanarFace::Front).unwrap(); + let front = end(&facets, PlanarFace::Front).unwrap(); + assert!(map.visible && !front.away); + lands_on(front, &map, corners(bounds)); + assert_eq!(map.is_affine(), perspective == 0.0); + } +} + +#[test] +fn the_back_cap_follows_the_twist_and_taper() { + let (path, bounds) = square(); + let options = Extrude { twist: 35.0, taper: 0.5, ..Extrude::default() }; + // Seen from behind, the back cap faces the viewer and the front does not. + let view = View { rotation_y: 160.0, perspective: 50.0, ..turned(0.0) }; + let facets = extrude(&path, FillRule::NonZero, bounds, options, view).unwrap(); + let back = end(&facets, PlanarFace::Back).unwrap(); + let map = extrude_face_map(bounds, options, view, PlanarFace::Back).unwrap(); + assert!(map.visible); + assert!(!extrude_face_map(bounds, options, view, PlanarFace::Front).unwrap().visible); + assert!(end(&facets, PlanarFace::Front).is_none_or(|f| f.away)); + lands_on(back, &map, corners(bounds)); +} + +#[test] +fn a_plane_has_only_a_front_and_a_hollow_solid_none() { + let (path, bounds) = square(); + let view = turned(80.0); + let facets = plane(&path, FillRule::NonZero, bounds, view).unwrap(); + let map = plane_face_map(bounds, view).unwrap(); + lands_on(end(&facets, PlanarFace::Front).unwrap(), &map, corners(bounds)); + let flat = Extrude { depth: 0.0, ..Extrude::default() }; + assert_eq!(extrude_face_map(bounds, flat, view, PlanarFace::Front), Some(map)); + assert_eq!(extrude_face_map(bounds, flat, view, PlanarFace::Back), None); + let hollow = Extrude { cap: false, ..Extrude::default() }; + assert_eq!(extrude_face_map(bounds, hollow, view, PlanarFace::Front), None); + let facets = extrude(&path, FillRule::NonZero, bounds, hollow, view).unwrap(); + assert!(facets.iter().all(|f| f.end.is_none())); +} + +#[test] +fn page_points_map_back_to_the_art() { + let (_, bounds) = square(); + for perspective in [0.0, 120.0] { + let map = extrude_face_map(bounds, Extrude::default(), turned(perspective), PlanarFace::Front).unwrap(); + for p in [Point::new(10.0, 20.0), Point::new(45.0, 33.0), Point::new(-200.0, 400.0)] { + let q = map.to_page(p).unwrap(); + let back = map.to_art(q).unwrap(); + assert!(back.distance(p) < 1e-6, "{p:?} came back as {back:?}"); + } + } +} + +#[test] +fn the_local_affine_map_matches_the_face_near_its_point() { + let (_, bounds) = square(); + let flat = extrude_face_map(bounds, Extrude::default(), turned(0.0), PlanarFace::Front).unwrap(); + let m = flat.local_affine(Point::new(40.0, 40.0)).unwrap(); + for p in corners(bounds) { + assert!((m * p).distance(flat.to_page(p).unwrap()) < 1e-9); + } + let deep = extrude_face_map(bounds, Extrude::default(), turned(120.0), PlanarFace::Front).unwrap(); + let at = Point::new(40.0, 40.0); + let [a, b, c, d, ..] = deep.local_affine(at).unwrap().as_coeffs(); + let h = 1e-4; + let slope = |dx: f64, dy: f64| { + let (p, q) = (deep.to_page(Point::new(at.x + dx, at.y + dy)).unwrap(), deep.to_page(Point::new(at.x - dx, at.y - dy)).unwrap()); + ((p.x - q.x) / (2.0 * h), (p.y - q.y) / (2.0 * h)) + }; + let ((ax, bx), (cy, dy)) = (slope(h, 0.0), slope(0.0, h)); + for (got, want) in [(a, ax), (b, bx), (c, cy), (d, dy)] { + assert!((got - want).abs() < 1e-6, "{got} against {want}"); + } + assert!((deep.local_affine(at).unwrap() * at).distance(deep.to_page(at).unwrap()) < 1e-9); +} + +#[test] +fn a_point_behind_the_eye_has_no_page_point() { + let (_, bounds) = square(); + // Seen almost edge-on at the widest perspective, far points of the plane pass behind the eye. + let view = View { rotation_x: -89.0, perspective: 160.0, ..turned(0.0) }; + let map = plane_face_map(bounds, view).unwrap(); + assert!(map.to_page(Point::new(40.0, -1e6)).is_none() || map.to_page(Point::new(40.0, 1e6)).is_none()); +} + +#[test] +fn a_page_point_nearer_the_eye_than_the_page_map_allows_has_no_art_point() { + let (_, bounds) = square(); + let view = View { rotation_x: -89.0, perspective: 160.0, ..turned(0.0) }; + let map = plane_face_map(bounds, view).unwrap(); + let limit = map.eye.unwrap() * NEAREST; + // An art point in front of the eye, but at half the nearest depth `to_page` maps. + let [a, b, c] = map.rows[2]; + let p0 = bounds.center(); + let t = (limit / 2.0 - (a * p0.x + b * p0.y + c)) / (a * a + b * b); + let p = Point::new(p0.x + t * a, p0.y + t * b); + let [u, v, w] = map.homogeneous(p); + assert!(w > 0.0 && w < limit, "{w}"); + assert_eq!(map.to_page(p), None); + let q = Point::new(map.origin.x + u / w, map.origin.y + v / w); + assert_eq!(map.to_art(q), None, "the inverse map takes {q:?} back to an art point the page map has no point for"); +} + +#[test] +fn the_mapped_part_of_a_rectangle_stops_before_the_eye() { + let (_, bounds) = square(); + let view = View { rotation_x: -89.0, perspective: 160.0, ..turned(0.0) }; + let map = plane_face_map(bounds, view).unwrap(); + let r = Rect::new(-1e6, -1e6, 1e6, 1e6); + let part = map.mapped_part(r).unwrap(); + // Some of the rectangle passes behind the eye, and every corner of what is left maps. + assert!((3..=5).contains(&part.len())); + assert!(part.iter().all(|p| map.to_page(*p).is_some()), "{part:?}"); + assert!(corners(r).iter().any(|p| !part.contains(p))); + // Without perspective, all of it maps. + let flat = plane_face_map(bounds, View { perspective: 0.0, ..view }).unwrap(); + assert_eq!(flat.mapped_part(r).map(|p| p.len()), Some(4)); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f68fbee17f..d75eae537f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -410,6 +410,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { draw_overlays(&painter, &xf, &overlays, &t, HandleLook::of(&app.session.prefs)); if !revolve_edit { shaper_widget_overlay(app, &painter, &xf); + crate::panels::three_d::paint_graphic_handles(app, &painter, &xf); } } ime_geometry(app, ui.ctx(), &xf); @@ -444,14 +445,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } /// The pointer on the canvas: the tool's input, unless the context menu or the 3D gizmo has the press. -fn canvas_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) { - // A press on the canvas while the context menu is open only closes it (a drag too, which egui - // alone would leave open); the tool doesn't get it. +pub(crate) fn canvas_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) { if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) { // The artboard's 3D gizmo owns these gestures while its options are open. // Do not let a ring drag also move or deselect the editable source path. } else if resp.context_menu_opened() && resp.hovered() && ui.input(|i| i.pointer.any_pressed()) { + // A press on the canvas while the context menu is open only closes it (a drag too, which + // egui alone would leave open); neither the tool nor a graphic's handle gets it. egui::Popup::close_all(ui.ctx()); + } else if crate::panels::three_d::graphic_handles_input(app, ui, resp, rect) { + // A mapped graphic's handle, shown while the 3D and Materials panel's Materials tab is + // open, has the press: the drag moves the graphic, not the object. } else { handle_input(app, ui, resp, rect); // `hover_doc` is the document point under the pointer in the view `handle_input` left. The diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 0cd63cd9a9..b40e8c790e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -132,6 +132,7 @@ pub mod tate_chu_yoko; mod text_export; pub mod text_import; mod three_d_classic; +pub(crate) mod three_d_materials; mod tiff_bmp_tga; pub mod tile_edge_color; mod tools; diff --git a/crates/ui-egui/src/dialogs/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs index c7a306887e..de21c368bc 100644 --- a/crates/ui-egui/src/dialogs/revolve.rs +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -37,6 +37,14 @@ pub(super) fn body(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let id = d.str("__effect"); let defaults = vectorcraft_effects::default_params(&id).unwrap_or(json!({})); + // Reset all resets the shape, view and light; the mapped graphics stay. + let defaults = match defaults { + Value::Object(mut m) => { + m.remove(vectorcraft_effects::GRAPHICS); + Value::Object(m) + } + other => other, + }; let changed = defaults.as_object().is_some_and(|m| { m.iter().any(|(k, v)| v.as_f64().map_or_else(|| d.fields.get(k) != Some(v), |default| (d.f64(k, default) - default).abs() > 1e-6)) }); @@ -71,14 +79,24 @@ pub(crate) fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) return solid_controls(app, ui, d, &id); } let lighting = super::revolve_gizmo::lighting(d); - if let Some(tab) = widgets::tab_bar(ui, &[tl!("Rotation"), tl!("Lighting")], usize::from(lighting)) { - d.fields.insert("__gizmo".into(), json!(if tab == 1 { "light" } else { "rotation" })); + // The panel adds the Materials tab between Rotation and Lighting. + let panel = d.bool("__panel"); + let tabs: Vec<&str> = if panel { vec![tl!("Rotation"), tl!("Materials"), tl!("Lighting")] } else { vec![tl!("Rotation"), tl!("Lighting")] }; + let current = if lighting { tabs.len() - 1 } else { usize::from(materials(d)) }; + if let Some(tab) = widgets::tab_bar(ui, &tabs, current) { + let light = tab + 1 == tabs.len(); + d.fields.insert("__gizmo".into(), json!(if light { "light" } else { "rotation" })); + d.fields.insert("__tab".into(), json!(if panel && tab == 1 && !light { "materials" } else { "rotation" })); } ui.add_space(8.0); if super::revolve_gizmo::lighting(d) { lighting_controls(ui, d); return; } + if materials(d) { + super::three_d_materials::controls(app, ui, d); + return; + } let unit = app.session.general_unit(); heading(ui, tl!("Shape")); number(ui, d, "angle", tl!("Revolve angle"), (0.0, 360.0), "°", 1, None); @@ -140,6 +158,11 @@ pub(crate) fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) }); } +/// Is the 3D and Materials panel's Materials tab open (the dialog has none)? +pub(crate) fn materials(d: &Dialog) -> bool { + d.bool("__panel") && d.str("__tab") == "materials" && !super::revolve_gizmo::lighting(d) +} + /// `s` as the 3D effects mean it (a solid's cap, not a stroke's; a bevelled edge, not a join). pub(super) fn c3(s: &'static str) -> &'static str { crate::i18n::tr_ctx(crate::i18n::current(), "3d", s) @@ -155,18 +178,34 @@ fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: & if object { tabs.push(tl!("Object")); } - tabs.extend([tl!("Rotation"), tl!("Lighting")]); + tabs.push(tl!("Rotation")); + let rotation_at = tabs.len() - 1; + // The panel adds the Materials tab before Lighting (Extrude & Bevel (Classic) has none). + let materials_at = (d.bool("__panel") && id != CLASSIC_EXTRUDE).then(|| { + tabs.push(tl!("Materials")); + tabs.len() - 1 + }); + tabs.push(tl!("Lighting")); let current = if super::revolve_gizmo::lighting(d) { tabs.len() - 1 + } else if let Some(at) = materials_at.filter(|_| materials(d)) { + at } else if object && d.str("__tab") != "rotation" { 0 } else { - tabs.len() - 2 + rotation_at }; if let Some(tab) = widgets::tab_bar(ui, &tabs, current) { let light = tab + 1 == tabs.len(); d.fields.insert("__gizmo".into(), json!(if light { "light" } else { "rotation" })); - d.fields.insert("__tab".into(), json!(if object && tab == 0 { "object" } else { "rotation" })); + let kind = if object && tab == 0 { + "object" + } else if Some(tab) == materials_at { + "materials" + } else { + "rotation" + }; + d.fields.insert("__tab".into(), json!(kind)); } ui.add_space(8.0); if super::revolve_gizmo::lighting(d) { @@ -175,6 +214,8 @@ fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: & } else { lighting_controls(ui, d); } + } else if materials_at.is_some() && materials(d) { + super::three_d_materials::controls(app, ui, d); } else if object && d.str("__tab") != "rotation" { let unit = app.session.general_unit(); if id == CLASSIC_EXTRUDE { @@ -534,7 +575,11 @@ 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)] 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); + // Values that are not the effect's own parameters (a mapped graphic's) bring their defaults. + let default = vectorcraft_effects::default_params(&d.str("__effect")) + .and_then(|p| p.get(key).and_then(Value::as_f64)) + .or_else(|| d.fields.get("__defaults").and_then(|m| m.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)); let typed = unit.map(|u| widgets::typed_unit(ui.ctx(), u)); diff --git a/crates/ui-egui/src/dialogs/three_d_materials.rs b/crates/ui-egui/src/dialogs/three_d_materials.rs new file mode 100644 index 0000000000..180572aeaa --- /dev/null +++ b/crates/ui-egui/src/dialogs/three_d_materials.rs @@ -0,0 +1,162 @@ +//! The 3D and Materials panel's Materials tab: the document's Graphics (its symbols) as thumbnails, +//! Add as Single Graphic, and the graphics mapped onto the selected 3D object's planar faces with +//! their face, position, size and turn. Picking a thumbnail maps that graphic onto the selected +//! objects (`threeD.mapGraphic`); art dropped on the thumbnails becomes a graphic +//! (`threeD.addGraphic`). Editing a mapped graphic changes the effect's `graphics` parameter, which +//! the panel sends as it sends every other setting. + +use egui::{Sense, Stroke, StrokeKind, vec2}; +use serde_json::{Value, json}; +use vectorcraft_effects::{GRAPHICS, MappedGraphic, PlanarFace, ROTATE}; + +use super::revolve::{heading, label, number}; +use crate::VectorcraftApp; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::widgets::{self, dim_label}; + +/// The field of [`Dialog::fields`] that holds what the panel runs after drawing the tab +/// ([`Action`]), as JSON. It is UI state and never enters the effect's parameters. +pub(crate) const ACTION: &str = "__graphicAction"; +/// The field that holds the index of the mapped graphic the tab edits. +pub(crate) const SELECTED: &str = "__graphic"; + +/// What a click in the Graphics list asks the panel to do. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Action { + /// Map the graphic of this symbol onto the selected 3D objects. + Map(String), + /// Keep art as a new graphic: the objects dropped on the list, or the selection (`None`). + Add(Option>), +} + +impl Action { + pub(crate) fn to_value(&self) -> Value { + match self { + Action::Map(name) => json!({"map": name}), + Action::Add(ids) => json!({"add": ids}), + } + } + + pub(crate) fn from_value(v: &Value) -> Option { + if let Some(name) = v.get("map").and_then(Value::as_str) { + return Some(Action::Map(name.to_string())); + } + let ids = v.get("add")?; + Some(Action::Add(ids.as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()))) + } +} + +/// The document's graphics as thumbnails, with Add as Single Graphic below them. A thumbnail +/// clicked while `apply` (a 3D object is selected) maps that graphic. Returns the action asked for. +pub(crate) fn library(app: &VectorcraftApp, ui: &mut egui::Ui, apply: bool) -> Option { + heading(ui, tl!("Graphics")); + let st = app.session.active()?; + let names: Vec = st.doc.symbols.iter().map(|s| s.name.clone()).collect(); + let keys = crate::panels::symbols::document_symbols(st); + let t = Tokens::get(ui.ctx()); + let mut action = None; + let area = ui + .horizontal_wrapped(|ui| { + for name in &names { + let (r, resp) = ui.allocate_exact_size(vec2(44.0, 44.0), Sense::click()); + crate::panels::symbols::chip(ui, &st.doc, &keys, r.shrink(2.0), name); + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Inside); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), name)); + let tip = if apply { name.clone() } else { format!("{name}: {}", tl!("Select a 3D object to map the graphic onto it.")) }; + if resp.on_hover_text(tip).clicked() && apply { + action = Some(Action::Map(name.clone())); + } + } + if names.is_empty() { + dim_label(ui, tl!("Add art with Add as Single Graphic, or drop art here.")); + } + }) + .response + .rect; + let zone = ui.interact(area, ui.scope_id().with("three-d-graphics-drop"), Sense::hover()); + if let Some(ids) = widgets::art_drop(ui, &zone) { + action = Some(Action::Add(Some(ids))); + } + let selected = !st.selection.objects.is_empty(); + if ui.add_enabled(selected, egui::Button::new(tl!("Add as Single Graphic"))).clicked() { + action = Some(Action::Add(None)); + } + action +} + +/// The Materials tab of the 3D effect in `d.fields["__effect"]`: the Graphics list, then the +/// graphics mapped onto the object, the selected one with its settings. Edits `d.fields`; a click +/// in the list is left in [`ACTION`]. +pub(crate) fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { + let effect = d.str("__effect"); + let takes = vectorcraft_effects::takes_graphics(&effect); + if let Some(action) = library(app, ui, takes) { + d.fields.insert(ACTION.into(), action.to_value()); + } + ui.add_space(10.0); + if !takes { + dim_label(ui, tl!("Graphics map onto planar faces: pick the Plane or Extrude object type.")); + return; + } + let raw = d.fields.get(GRAPHICS).cloned().unwrap_or(Value::Null); + if raw.is_null() && d.fields.contains_key(GRAPHICS) { + dim_label(ui, tl!("The selected objects have different graphics.")); + return; + } + let mut list: Vec = vectorcraft_effects::mapped_graphics(&json!({ GRAPHICS: raw })); + heading(ui, tl!("Mapped graphics")); + if list.is_empty() { + dim_label(ui, tl!("Pick a graphic above to map it onto the object.")); + return; + } + let mut index = d.fields.get(SELECTED).and_then(Value::as_u64).map_or(0, |i| i as usize).min(list.len() - 1); + for (i, g) in list.iter().enumerate() { + let face = if g.face == PlanarFace::Back { tl!("Back") } else { tl!("Front") }; + if ui.selectable_label(i == index, format!("{} · {face}", g.symbol)).clicked() { + index = i; + } + } + d.fields.insert(SELECTED.into(), json!(index)); + ui.add_space(6.0); + let Some(g) = list.get(index).cloned() else { return }; + let mut edited = g.clone(); + if effect != ROTATE { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + label(ui, tl!("Face")); + let labels = [tl!("Front"), tl!("Back")]; + let at = usize::from(g.face == PlanarFace::Back); + if let Some(k) = widgets::dropdown(ui, "three-d-graphic-face", labels[at], &labels, 120.0) { + edited.face = PlanarFace::ALL[k.min(1)]; + } + }); + } + let unit = app.session.general_unit(); + let mut fields = Dialog { kind: "threeDGraphic".into(), fields: Default::default() }; + fields.fields.insert("__defaults".into(), json!({"graphicX": 0.0, "graphicY": 0.0, "graphicScale": 100.0, "graphicRotation": 0.0})); + fields.fields.insert("graphicX".into(), json!(g.x)); + fields.fields.insert("graphicY".into(), json!(g.y)); + fields.fields.insert("graphicScale".into(), json!(g.scale)); + fields.fields.insert("graphicRotation".into(), json!(g.rotation)); + // The ranges the effect takes, so every stored value shows as it is. + let offsets = (-vectorcraft_effects::GRAPHIC_MAX_OFFSET, vectorcraft_effects::GRAPHIC_MAX_OFFSET); + number(ui, &mut fields, "graphicX", "X", offsets, "", 1, Some(unit)); + number(ui, &mut fields, "graphicY", "Y", offsets, "", 1, Some(unit)); + number(ui, &mut fields, "graphicScale", tl!("Scale"), vectorcraft_effects::GRAPHIC_SCALE_RANGE, "%", 1, None); + number(ui, &mut fields, "graphicRotation", tl!("Rotation"), (-360.0, 360.0), "°", 1, None); + edited.x = fields.f64("graphicX", g.x); + edited.y = fields.f64("graphicY", g.y); + edited.scale = fields.f64("graphicScale", g.scale); + edited.rotation = fields.f64("graphicRotation", g.rotation); + let remove = ui.button(tl!("Remove Graphic")).clicked(); + if remove { + list.remove(index); + d.fields.insert(SELECTED.into(), json!(index.saturating_sub(1))); + } else if edited != g { + list[index] = edited; + } else { + return; + } + d.fields.insert(GRAPHICS.into(), Value::Array(list.iter().map(MappedGraphic::to_value).collect())); +} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 582a927f09..ac42c2d669 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2103,6 +2103,7 @@ font-technology Variable Променливи Art Brush Артистична четка Bristle Brush Четка с косъм Pattern Brush Четка с мостра + missing from the Brushes panel липсва в панела „Четки“ Select art to make a scatter, art or pattern brush. Изберете графика, за да създадете разпръсната четка, артистична четка или четка с мостра. Brush Art Графика на четка New Calligraphic Brush Нова калиграфска четка diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index d92da7ae40..96aa3fe5a5 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -3322,6 +3322,7 @@ theme Appearance Izgled Art Brush Umjetnička četkica Bristle Brush Četkica s čekinjama Pattern Brush Četkica uzorka + missing from the Brushes panel nedostaje u panelu Četkice Select art to make a scatter, art or pattern brush. Odaberite crtež da napravite raspršenu, umjetničku četkicu ili četkicu uzorka. Brush Art Crtež četkice New Symbol Novi simbol diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 283dae1800..95b2615ec3 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3090,6 +3090,7 @@ digits Malayalam Malaiàlam Pattern Motiu Pattern Borders Vores de motiu Pattern Brush Pinzell de motiu + missing from the Brushes panel absent del tauler Pinzells Pattern Brush Options Opcions del pinzell de motiu Pattern Editing Edició de motius Pattern Options Opcions del motiu diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 519963c4eb..f27fad5417 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3372,6 +3372,7 @@ theme Appearance Motiv Art Brush Obrazový štětec Bristle Brush Štětinový štětec Pattern Brush Vzorkový štětec + missing from the Brushes panel chybí v panelu Štětce Select art to make a scatter, art or pattern brush. Vyberte kresbu, abyste mohli vytvořit rozptylový, obrazový nebo vzorkový štětec. Brush Art Kresba štětce New Symbol Nový symbol diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 3269755003..ff80e108b4 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3107,6 +3107,7 @@ theme Appearance Udseende Art Brush Grafikpensel Bristle Brush Børstepensel Pattern Brush Mønsterpensel + missing from the Brushes panel mangler i panelet Pensler Select art to make a scatter, art or pattern brush. Markér grafik for at oprette en spredningspensel, grafikpensel eller mønsterpensel. Brush Art Penselgrafik New Symbol Nyt symbol diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index f3858abc1f..d9434741c0 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2137,6 +2137,7 @@ file-filter Templates Vorlagen Art Brush Bildpinsel Bristle Brush Borstenpinsel Pattern Brush Musterpinsel + missing from the Brushes panel fehlt im Bedienfeld „Pinsel“ Select art to make a scatter, art or pattern brush. Wählen Sie eine Grafik aus, um einen Spezial-, Bild- oder Musterpinsel zu erstellen. Brush Art Pinselgrafik New Calligraphic Brush Neuer Kalligrafiepinsel diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 52a12276f7..1ceb93c669 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1814,6 +1814,7 @@ font-technology Variable Μεταβλητές Art Brush Πινέλο σχεδίου Bristle Brush Πινέλο με τρίχες Pattern Brush Πινέλο μοτίβου + missing from the Brushes panel λείπει από τον πίνακα Πινέλα Select art to make a scatter, art or pattern brush. Επιλέξτε σχέδιο για να δημιουργήσετε πινέλο διασποράς, σχεδίου ή μοτίβου. Brush Art Σχέδιο πινέλου New Layer… Νέο επίπεδο… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e6f77cd908..7708e930bb 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2175,6 +2175,7 @@ font-technology Variable Variables Art Brush Pincel de arte Bristle Brush Pincel de cerdas Pattern Brush Pincel de motivo + missing from the Brushes panel falta en el panel Pinceles Select art to make a scatter, art or pattern brush. Selecciona una ilustración para crear un pincel de dispersión, de arte o de motivo. Brush Art Ilustración del pincel New Calligraphic Brush Nuevo pincel caligráfico diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index dc0e432264..765400c5a6 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3311,6 +3311,7 @@ theme Appearance Ulkoasu Art Brush Taidesivellin Bristle Brush Harjassivellin Pattern Brush Kuviosivellin + missing from the Brushes panel puuttuu Siveltimet-paneelista Select art to make a scatter, art or pattern brush. Valitse kuvitus, jotta voit luoda sirote-, taide- tai kuviosiveltimen. Brush Art Siveltimen kuvitus New Symbol Uusi symboli diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 3c74586ef0..f7eb341e39 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2167,6 +2167,7 @@ font-technology Variable Variables Art Brush Forme artistique Bristle Brush Forme Soies Pattern Brush Forme de motif + missing from the Brushes panel absente du panneau Formes Select art to make a scatter, art or pattern brush. Sélectionnez une illustration pour créer une forme diffuse, artistique ou de motif. Brush Art Illustration de la forme New Calligraphic Brush Nouvelle forme calligraphique diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 8e1ce7cc2a..978b6811fd 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3356,6 +3356,7 @@ antiAlias Sharp शार्प Pattern पैटर्न Pattern Borders पैटर्न सीमा Pattern Brush पैटर्न ब्रश + missing from the Brushes panel ब्रश पैनल में अनुपलब्ध Pattern Brush Options पैटर्न ब्रश ऑप्शन्स Pattern Editing पैटर्न संपादन Pattern Options पैटर्न ऑप्शन्स diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 15ce573c11..7bfe5b5682 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2984,6 +2984,7 @@ file-filter Templates Sablonok Paths: {paths} Anchors: {anchors} Colors: {colors} Útvonalak: {paths} Horgonypontok: {anchors} Színek: {colors} Pattern Mintázat Pattern Brush Mintázatecset + missing from the Brushes panel hiányzik az Ecsetek panelről Pattern Brush Options Mintázatecset beállításai Pattern Editing Mintázatszerkesztés Pattern Options Mintázat beállításai diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index f68fad0c1c..ebbaa2dccd 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3107,6 +3107,7 @@ theme Appearance Tampilan Art Brush Kuas seni Bristle Brush Kuas bristle Pattern Brush Kuas pola + missing from the Brushes panel tidak ada di panel Kuas Select art to make a scatter, art or pattern brush. Pilih karya seni untuk membuat kuas sebar, seni, atau pola. Brush Art Karya seni kuas New Symbol Simbol baru diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 480115859b..d9aa6ea3c8 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2213,6 +2213,7 @@ file-filter Templates Modelli Art Brush Pennello artistico Bristle Brush Pennello a setole Pattern Brush Pennello motivo + missing from the Brushes panel mancante nel pannello Pennelli Select art to make a scatter, art or pattern brush. Seleziona la grafica per creare un pennello a dispersione, artistico o motivo. Brush Art Grafica del pennello New Calligraphic Brush Nuovo pennello calligrafico diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index ba3105aec0..5e337a9bc4 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2420,6 +2420,7 @@ antiAlias Sharp シャープ Art Brush アートブラシ Bristle Brush 剛毛ブラシ Pattern Brush パターンブラシ + missing from the Brushes panel ブラシパネルにありません Select art to make a scatter, art or pattern brush. 散布ブラシ、アートブラシ、パターンブラシを作成するには、アートを選択します。 Brush Art ブラシのアート New Calligraphic Brush 新規絵筆ブラシ diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 83fadf726a..e5bf998d5c 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -2950,6 +2950,7 @@ theme Appearance იერსახე Art Brush მხატვრული ფუნჯი Bristle Brush ჯაგრის ფუნჯი Pattern Brush პატერნის ფუნჯი + missing from the Brushes panel ფუნჯების პანელში არ არის Select art to make a scatter, art or pattern brush. მონიშნეთ ნახატი გაფანტვის, მხატვრული ან პატერნის ფუნჯის შესაქმნელად. Brush Art ფუნჯის ნახატი New Symbol ახალი სიმბოლო diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e6f7478bf3..b10632963f 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2410,6 +2410,7 @@ antiAlias Sharp 날카롭게 Art Brush 아트 브러시 Bristle Brush 강모 브러시 Pattern Brush 패턴 브러시 + missing from the Brushes panel 브러시 패널에 없음 Select art to make a scatter, art or pattern brush. 흩뿌리기, 아트 또는 패턴 브러시를 만들려면 아트웍을 선택하세요. Brush Art 브러시 아트 New Calligraphic Brush 새 캘리그래피 브러시 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 26baaa8dc5..66a01ed7ce 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3121,6 +3121,7 @@ theme Appearance Vormgeving Art Brush Artpenseel Bristle Brush Borstelpenseel Pattern Brush Patroonpenseel + missing from the Brushes panel ontbreekt in het deelvenster Penselen Select art to make a scatter, art or pattern brush. Selecteer illustratie om een verspreidings-, art- of patroonpenseel te maken. Brush Art Penseelillustratie New Symbol Nieuw symbool diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 3637bb6f94..2eca92f56b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3251,6 +3251,7 @@ theme Appearance Wygląd Art Brush Pędzel artystyczny Bristle Brush Pędzel włosiankowy Pattern Brush Pędzel wzorka + missing from the Brushes panel brak w panelu Pędzle Select art to make a scatter, art or pattern brush. Zaznacz kompozycję, aby utworzyć pędzel rozproszony, artystyczny lub wzorka. Brush Art Kompozycja pędzla New Symbol Nowy symbol diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 15aa70e244..24a3db3fe0 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1788,6 +1788,7 @@ antiAlias Sharp Nítido Art Brush Pincel de Arte Bristle Brush Pincel de Cerdas Pattern Brush Pincel de Padrão + missing from the Brushes panel ausente do painel Pincéis Select art to make a scatter, art or pattern brush. Selecione arte para criar um pincel de dispersão, de arte ou de padrão. Brush Art Arte do Pincel New Calligraphic Brush Novo Pincel Caligráfico diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 21f270a0fb..0c0c0bb1f0 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2200,6 +2200,7 @@ file-filter Templates Шаблоны Art Brush Художественная кисть Bristle Brush Щетинная кисть Pattern Brush Узорная кисть + missing from the Brushes panel отсутствует в панели «Кисти» Select art to make a scatter, art or pattern brush. Выделите рисунок, чтобы создать кисть-распыление, художественную или узорную кисть. Brush Art Рисунок кисти New Calligraphic Brush Новая каллиграфическая кисть diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 7969960880..911f406a57 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2114,6 +2114,7 @@ font-technology Variable Variabla Art Brush Konstpensel Bristle Brush Borstpensel Pattern Brush Mönsterpensel + missing from the Brushes panel saknas i panelen Penslar Select art to make a scatter, art or pattern brush. Markera grafik för att skapa en spridnings-, konst- eller mönsterpensel. Brush Art Penselgrafik New Calligraphic Brush Ny kalligrafisk pensel diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 639bf692a8..b85b65184b 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5916,6 +5916,7 @@ theme Appearance Görünüm Long Uzun New Brush Yeni Fırça Pattern Brush Desen Fırçası + missing from the Brushes panel Fırçalar panelinde yok Pick Color From: Rengi Buradan Al: Scatter Brush Serpme Fırçası Select art to make a scatter, art or pattern brush. Bir serpme, resim veya desen fırçası oluşturmak için çizim seçin. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 2889757941..8e05bb2fc9 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2205,6 +2205,7 @@ file-filter Templates Шаблони Art Brush Художній пензель Bristle Brush Щетинний пензель Pattern Brush Візерунковий пензель + missing from the Brushes panel відсутній у панелі «Пензлі» Select art to make a scatter, art or pattern brush. Виділіть малюнок, щоб створити пензель-розпорошення, художній або візерунковий пензель. Brush Art Малюнок пензля New Calligraphic Brush Новий каліграфічний пензель diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index ca39a2cd16..f10ab118e4 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2801,6 +2801,7 @@ digits Hindi Hindi Paths: {paths} Anchors: {anchors} Colors: {colors} Đường dẫn: {paths} Điểm neo: {anchors} Màu: {colors} Pattern Họa tiết Pattern Brush Cọ họa tiết + missing from the Brushes panel không có trong bảng Cọ Pattern Brush Options Tùy chọn cọ họa tiết Pattern Editing Chỉnh sửa họa tiết Pattern Options Tùy chọn họa tiết diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d4fe4ce4f3..b699270a8b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2074,6 +2074,7 @@ file-filter Templates 模板 Art Brush 艺术画笔 Bristle Brush 鬃毛画笔 Pattern Brush 图案画笔 + missing from the Brushes panel “画笔”面板中没有 Select art to make a scatter, art or pattern brush. 选择图稿以创建散布画笔、艺术画笔或图案画笔。 Brush Art 画笔图稿 New Calligraphic Brush 新建书法画笔 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 1a81d6d01c..83e4eb4a60 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3177,6 +3177,7 @@ file-filter Templates 範本 Art Brush 藝術筆刷 Bristle Brush 鬃毛筆刷 Pattern Brush 圖樣筆刷 + missing from the Brushes panel 「筆刷」面板中沒有 Select art to make a scatter, art or pattern brush. 選取圖稿以製作散佈筆刷、藝術筆刷或圖樣筆刷。 Brush Art 筆刷圖稿 New Calligraphic Brush 新增書法筆刷 diff --git a/crates/ui-egui/src/panels/doc_info.rs b/crates/ui-egui/src/panels/doc_info.rs index e450da63aa..1d4db19979 100644 --- a/crates/ui-egui/src/panels/doc_info.rs +++ b/crates/ui-egui/src/panels/doc_info.rs @@ -78,6 +78,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let label = super::label_or_name(label, !named.contains(&k)); if value.is_empty() { widgets::dim_name(ui, label); + } else if s["id"] == "brushes" { + row(ui, label, brush_value(value).into()); } else { row(ui, label, value.into()); } @@ -86,6 +88,20 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); } +/// A Brushes row's value in the interface language: the brush's type (`Art Brush`), or that the +/// Brushes panel does not hold the brush. `document.info` and the text report keep them in English. +fn brush_value(value: &str) -> &str { + match value { + "Calligraphic Brush" => tl!("Calligraphic Brush"), + "Scatter Brush" => tl!("Scatter Brush"), + "Art Brush" => tl!("Art Brush"), + "Pattern Brush" => tl!("Pattern Brush"), + "Bristle Brush" => tl!("Bristle Brush"), + "missing from the Brushes panel" => tl!("missing from the Brushes panel"), + _ => value, + } +} + /// The rows of Document Info section `id` labelled by a name (an artboard, a style, a swatch, a /// font, an image, a repeat object) rather than an interface label: every row of the lists, and in Document the /// artboards, which follow the Artboards count. Names are shown as they are. @@ -295,4 +311,30 @@ mod tests { let err = app.run("docInfo.save", json!({"path": "/tmp/info.txt", "category": "colors"})).unwrap_err(); assert!(err.contains("category `colors`"), "{err}"); } + + /// Brushes shows each brush's type, and a brush the Brushes panel does not hold, in the + /// interface language; the saved report keeps them in English. + #[test] + fn brush_types_show_in_the_interface_language() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let r = app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone(); + app.session.execute("brush.apply", &json!({"ids": [r], "name": "Charcoal"})).unwrap(); + let brushes = |app: &mut VectorcraftApp| { + crate::tests_labels::painted_text(app, |app, ui| { + set_pstate(ui.ctx(), "docinfo-category", "brushes".to_string()); + show(app, ui); + }) + }; + let english = brushes(&mut app); + assert!(english.contains("Charcoal") && english.contains("Art Brush"), "{english}"); + let Some(de) = crate::i18n::Lang::from_code("de") else { panic!("no German catalog") }; + crate::i18n::set_current(de); + let (german, missing) = (brushes(&mut app), brush_value("missing from the Brushes panel").to_string()); + crate::i18n::set_current(crate::i18n::Lang::EN); + assert!(german.contains("Charcoal") && german.contains("Bildpinsel") && !german.contains("Art Brush"), "{german}"); + assert_eq!(missing, "fehlt im Bedienfeld „Pinsel“"); + let report = app.session.execute("document.info", &json!({"category": "brushes", "format": "text"})).unwrap()["text"].to_string(); + assert!(report.contains("Charcoal: Art Brush"), "{report}"); + } } diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index 9682904538..bfc17c6687 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -1,5 +1,6 @@ -//! Swatches panel: proxy, Recent Colors, find field, thumbnail grid (15.5 pt tiles on a 17 pt pitch) -//! or list, colour groups as folders, None/Registration first, bottom bar and panel menu. +//! Swatches panel: proxy, Recent Colors (hidden by the panel menu's Show Recent Colors), find +//! field, thumbnail grid (15.5 pt tiles on a 17 pt pitch) or list, colour groups as folders, +//! None/Registration first, bottom bar and panel menu. //! //! Swatches drag to reorder, into and out of colour groups (`swatch.move`) and onto art (the active //! proxy's paint command with the object's id) and onto the Gradient panel's ramp; a Fill/Stroke diff --git a/crates/ui-egui/src/panels/swatches/actions.rs b/crates/ui-egui/src/panels/swatches/actions.rs index be275c108b..06c8935d30 100644 --- a/crates/ui-egui/src/panels/swatches/actions.rs +++ b/crates/ui-egui/src/panels/swatches/actions.rs @@ -182,6 +182,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Show Find Field"), true, find) { set_pstate(ui.ctx(), "swatch-show-find", !find); } + let recent = app.ui.swatches_show_recent_colors; + if menu_item(ui, tl!("Show Recent Colors"), true, recent) { + app.ui.swatches_show_recent_colors = !recent; + } ui.separator(); if menu_item(ui, tl!("Swatch Options…"), opts.is_some(), false) && let Some(n) = opts diff --git a/crates/ui-egui/src/panels/swatches/body.rs b/crates/ui-egui/src/panels/swatches/body.rs index 611b12d6d3..b97b10ab90 100644 --- a/crates/ui-egui/src/panels/swatches/body.rs +++ b/crates/ui-egui/src/panels/swatches/body.rs @@ -31,12 +31,14 @@ pub(super) fn body(app: &mut VectorcraftApp, ui: &mut Ui, salt: &'static str) { }); ui.add_space(4.0); // A recent colour paints the active proxy (Alt: the inactive one), as a swatch does, and - // becomes the Color Guide's base color. - if let Some(c) = crate::panels::recent_colors_row(app, ui) { - crate::panels::color_guide::choose_base(app, c); - crate::panels::apply_click(app, ui, json!({"color": crate::panels::color_json(&c)})); + // becomes the Color Guide's base color. The panel menu's Show Recent Colors hides the row. + if app.ui.swatches_show_recent_colors { + if let Some(c) = crate::panels::recent_colors_row(app, ui) { + crate::panels::color_guide::choose_base(app, c); + crate::panels::apply_click(app, ui, json!({"color": crate::panels::color_json(&c)})); + } + widgets::divider(ui); } - widgets::divider(ui); widgets::subheader(ui, tl!("Swatch Tiles")); let query = if pstate(ui.ctx(), "swatch-show-find") { widgets::search_field(ui, find_id(), tl!("Find")) } else { String::new() }; let items = entries(app, kind, &query); diff --git a/crates/ui-egui/src/panels/swatches/tests.rs b/crates/ui-egui/src/panels/swatches/tests.rs index 3586f91607..719262f451 100644 --- a/crates/ui-egui/src/panels/swatches/tests.rs +++ b/crates/ui-egui/src/panels/swatches/tests.rs @@ -901,3 +901,61 @@ fn lab_spot_tiles_show_the_colour_the_spot_colors_options_pick() { assert_eq!(tile(&app), app.session.active().unwrap().doc.global_color("Ink").unwrap()); assert!(matches!(tile(&app), Color::Cmyk { .. })); } + +/// One headless frame of the panel menu; returns its painted labels. +fn menu_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec<(String, Rect)> { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 700.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| menu(app, ui)); + out.textures_delta.clear(); + crate::tests_removeanchors::shapes_text(&out.shapes.iter().map(|c| c.shape.clone()).collect::>()) +} + +/// Click the panel menu's item `label`. +fn click_menu_item(app: &mut VectorcraftApp, ctx: &egui::Context, label: &str) { + let labels = menu_frame(app, ctx, vec![]); + let at = labels.iter().find(|(t, _)| t.trim() == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {label} in the menu")); + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + menu_frame(app, ctx, vec![Event::PointerMoved(at), button(true)]); + menu_frame(app, ctx, vec![button(false)]); +} + +/// The panel menu's Show Recent Colors hides and shows the panel's Recent Colors row (#6617): +/// checked by default, applied colors still join the list while the row is hidden, and the Color +/// panel keeps its own row and setting. +#[test] +fn show_recent_colors_hides_and_shows_the_swatches_row() { + let mut app = app(); + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + let ctx = context(); + let menu_ctx = context(); + // The first recent chip, if the panel draws one (the second frame's answer counts, since egui + // also answers for a widget the frame before drew). + let chip = |app: &mut VectorcraftApp| { + let mut chip = None; + for _ in 0..2 { + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0))); + let mut out = ctx.run_ui(egui::RawInput { screen_rect, ..Default::default() }, |ui| { + show(app, ui); + chip = ctx.read_response(ui.scope_id().with(("recent", 0usize))).map(|r| r.rect); + }); + out.textures_delta.clear(); + } + chip + }; + assert!(chip(&mut app).is_some(), "the row shows by default"); + click_menu_item(&mut app, &menu_ctx, "✓ Show Recent Colors"); + assert!(!app.ui.swatches_show_recent_colors); + assert!(chip(&mut app).is_none(), "unchecked, the row is hidden"); + app.run("paint.setFill", json!({"color": "#00ff00"})).unwrap(); + assert_eq!(app.session.recent_colors.first().map(|c| c.to_hex()).as_deref(), Some("#00ff00"), "colors still join the list"); + assert_eq!(app.run("ui.colorPanel", json!({})).unwrap()["recentColors"], true, "the Color panel keeps its setting"); + click_menu_item(&mut app, &menu_ctx, "Show Recent Colors"); + assert!(chip(&mut app).is_some(), "checking the item shows the row again"); + // The app keeps it with the preferences; a saved value that isn't true or false reads as on. + let saved = |v: Value| { + let mut prefs = serde_json::to_value(crate::UiState::default()).unwrap(); + prefs["swatches_show_recent_colors"] = v; + serde_json::from_value::(prefs).unwrap().swatches_show_recent_colors + }; + assert_eq!((saved(json!(false)), saved(json!(true)), saved(json!("no"))), (false, true, true)); +} diff --git a/crates/ui-egui/src/panels/tests_three_d.rs b/crates/ui-egui/src/panels/tests_three_d.rs index 29751dd5d7..9cebea0ba6 100644 --- a/crates/ui-egui/src/panels/tests_three_d.rs +++ b/crates/ui-egui/src/panels/tests_three_d.rs @@ -401,6 +401,298 @@ fn a_mixed_revolve_cap_shows_a_dash() { assert_eq!(undo_len(&app), history); } +/// The app with a graphic "Graphic" made from a red square beside the blue one, and the blue square +/// selected with Extrude & Bevel straight on; a context whose panel shows the Materials tab. +fn materials_app() -> (VectorcraftApp, egui::Context) { + let mut app = app(); + let square = app.session.active().unwrap().selection.objects[0].0; + let red = app.run("shape.rectangle", json!({"x": 300, "y": 300, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + assert_eq!(app.run("threeD.addGraphic", json!({"ids": [red]})).unwrap()["name"], "Graphic"); + app.run("select.set", json!({"ids": [square]})).unwrap(); + set_type(&mut app, "extrude").unwrap(); + app.run("effect.setParams", json!({"index": 0, "item": null, "params": {"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0}})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::panels::set_pstate(&ctx, super::UI_STATE, Map::from_iter([("__tab".to_string(), json!("materials"))])); + (app, ctx) +} + +fn graphics(app: &VectorcraftApp) -> Value { + effect(app).1.get("graphics").cloned().unwrap_or(Value::Null) +} + +#[test] +fn the_materials_tab_lists_the_graphics_and_maps_one_in_one_undo_step() { + let (mut app, ctx) = materials_app(); + ctx.enable_accesskit(); + let nodes = controls(&mut app, &ctx); + named(&nodes, "Add as Single Graphic"); + named(&nodes, "Graphic"); + let history = undo_len(&app); + super::run_action(&mut app, &ctx, crate::dialogs::three_d_materials::Action::Map("Graphic".into())); + assert_eq!(graphics(&app).as_array().map(Vec::len), Some(1)); + assert_eq!(graphics(&app)[0]["symbol"], "Graphic"); + assert_eq!(undo_len(&app), history + 1); + // The mapped graphic is listed with its settings, and Remove Graphic takes it off. + let nodes = controls(&mut app, &ctx); + named(&nodes, "Remove Graphic"); + named(&nodes, "Scale"); + // Inflate has no planar faces to map onto. + set_type(&mut app, "inflate").unwrap(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui)); + out.textures_delta.clear(); + let text = crate::tests_labels::shapes_text(&out); + assert!(text.contains("Graphics map onto planar faces"), "{text}"); +} + +#[test] +fn art_can_be_added_as_a_graphic_before_any_3d_effect() { + let mut app = app(); + let history = undo_len(&app); + let ctx = egui::Context::default(); + super::run_action(&mut app, &ctx, crate::dialogs::three_d_materials::Action::Add(None)); + assert_eq!(undo_len(&app), history + 1); + assert_eq!(app.session.active().unwrap().doc.symbols.len(), 1); + // The selection is still the square, not an instance. + let id = app.session.active().unwrap().selection.objects[0]; + assert!(matches!(app.session.active().unwrap().doc.node(id).unwrap().kind, vectorcraft_doc::NodeKind::Path { .. })); + assert!(painted_text(&mut app, show).contains("Add as Single Graphic")); +} + +#[test] +fn a_graphics_handle_drags_it_over_its_face_in_one_undo_step() { + let (mut app, ctx) = materials_app(); + app.run("threeD.mapGraphic", json!({"symbol": "Graphic"})).unwrap(); + let history = undo_len(&app); + let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 600.0)); + let xf = crate::canvas::Xf::new(rect, &app.view().cloned().unwrap_or_default()); + // Straight on, the graphic's handle is at the center of the square. + let handle = xf.to_screen(vectorcraft_geom::Point::new(150.0, 150.0)); + let target = xf.to_screen(vectorcraft_geom::Point::new(170.0, 140.0)); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut taken = false; + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + crate::panels::set_pstate(ui.ctx(), super::DRAWN, Some(super::root_pass(ui.ctx()))); + let resp = ui.allocate_rect(rect, egui::Sense::click_and_drag()); + taken = super::graphic_handles_input(app, ui, &resp, rect); + }) + .textures_delta + .clear(); + taken + }; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + // A press away from the handle is left to the tool. + let away = xf.to_screen(vectorcraft_geom::Point::new(110.0, 110.0)); + frame(&mut app, vec![egui::Event::PointerMoved(away)]); + assert!(!frame(&mut app, vec![button(away, true)])); + frame(&mut app, vec![button(away, false)]); + frame(&mut app, vec![egui::Event::PointerMoved(handle)]); + assert!(frame(&mut app, vec![button(handle, true)])); + assert!(frame(&mut app, vec![egui::Event::PointerMoved(target)])); + assert_eq!(undo_len(&app), history, "previewed while the pointer is down"); + assert!(frame(&mut app, vec![button(target, false)])); + assert_eq!(undo_len(&app), history + 1); + let g = &graphics(&app)[0]; + assert!((g["x"].as_f64().unwrap() - 20.0).abs() < 0.11 && (g["y"].as_f64().unwrap() + 10.0).abs() < 0.11, "{g}"); + // With another selected object whose graphics differ, the artboard shows no handles. + let square = app.session.active().unwrap().selection.objects[0].0; + let other = app.run("shape.rectangle", json!({"x": 400, "y": 400, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {}})).unwrap(); + app.run("select.set", json!({"ids": [square, other]})).unwrap(); + frame(&mut app, vec![egui::Event::PointerMoved(target)]); + assert!(!frame(&mut app, vec![button(target, true)])); + frame(&mut app, vec![button(target, false)]); + app.run("select.set", json!({"ids": [square]})).unwrap(); + // With its 3D effect hidden and a visible one after it, the hidden effect's graphics show no + // handles. + { + let st = app.session.active_mut().unwrap(); + let id = st.selection.objects[0]; + let n = std::sync::Arc::make_mut(&mut st.doc).node_mut(id).unwrap(); + n.appearance.effects[0].visible = false; + n.appearance.effects.push(vectorcraft_effects::new_effect("threeD.extrude", &json!({})).unwrap()); + } + let mut shown = true; + ctx.run_ui(egui::RawInput::default(), |ui| { + crate::panels::set_pstate(ui.ctx(), super::DRAWN, Some(super::root_pass(ui.ctx()))); + shown = super::graphic_handles(&app, ui.ctx()).is_some(); + }) + .textures_delta + .clear(); + assert!(!shown); + // With the tab closed, the artboard shows no handles. + crate::panels::set_pstate(&ctx, super::UI_STATE, Map::from_iter([("__tab".to_string(), json!("rotation"))])); + frame(&mut app, vec![egui::Event::PointerMoved(target)]); + assert!(!frame(&mut app, vec![button(target, true)])); +} + +/// One frame of the artboard's graphic handles over an 800 × 600 canvas with `events`: whether the +/// handles took the pointer. +fn handles_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> bool { + let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 600.0)); + let mut taken = false; + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + crate::panels::set_pstate(ui.ctx(), super::DRAWN, Some(super::root_pass(ui.ctx()))); + let resp = ui.allocate_rect(rect, egui::Sense::click_and_drag()); + taken = super::graphic_handles_input(app, ui, &resp, rect); + }) + .textures_delta + .clear(); + taken +} + +/// Where document point (`x`, `y`) is on the 800 × 600 canvas of [`handles_frame`]. +fn handles_screen(app: &VectorcraftApp, x: f64, y: f64) -> egui::Pos2 { + let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 600.0)); + crate::canvas::Xf::new(rect, &app.view().cloned().unwrap_or_default()).to_screen(vectorcraft_geom::Point::new(x, y)) +} + +fn primary(pos: egui::Pos2, pressed: bool) -> egui::Event { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() } +} + +#[test] +fn a_view_gesture_on_a_graphics_handle_is_left_to_the_canvas() { + let (mut app, ctx) = materials_app(); + app.run("threeD.mapGraphic", json!({"symbol": "Graphic"})).unwrap(); + let handle = handles_screen(&app, 150.0, 150.0); + let space = |pressed| egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + handles_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(handle)]); + // With Space held, the press pans the view. + handles_frame(&mut app, &ctx, vec![space(true)]); + assert!(!handles_frame(&mut app, &ctx, vec![primary(handle, true)])); + handles_frame(&mut app, &ctx, vec![primary(handle, false), space(false)]); + // With the Hand, Zoom or Rotate View tool, the press is the tool's. + for tool in ["hand", "zoom", "rotateView"] { + app.select_tool(tool); + assert!(!handles_frame(&mut app, &ctx, vec![primary(handle, true)]), "{tool}"); + handles_frame(&mut app, &ctx, vec![primary(handle, false)]); + } + // With another tool and Space up, the press grabs the handle. + app.select_tool("selection"); + assert!(handles_frame(&mut app, &ctx, vec![primary(handle, true)])); + handles_frame(&mut app, &ctx, vec![primary(handle, false)]); +} + +#[test] +fn a_geometry_effect_after_the_3d_effect_hides_the_graphics_handles() { + let (mut app, ctx) = materials_app(); + app.run("threeD.mapGraphic", json!({"symbol": "Graphic"})).unwrap(); + let handle = handles_screen(&app, 150.0, 150.0); + let push_effect = |app: &mut VectorcraftApp, object: usize, id: &str, params: Value| { + let st = app.session.active_mut().unwrap(); + let object = st.selection.objects[object]; + let n = std::sync::Arc::make_mut(&mut st.doc).node_mut(object).unwrap(); + n.appearance.effects.truncate(1); + n.appearance.effects.push(vectorcraft_effects::new_effect(id, ¶ms).unwrap()); + }; + // A Transform effect after the 3D effect draws the graphic 40 pt to the right of the frame's + // place for it: the artboard shows no handle there. + push_effect(&mut app, 0, "distort.transform", json!({"moveH": 40.0})); + handles_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(handle)]); + assert!(!handles_frame(&mut app, &ctx, vec![primary(handle, true)])); + handles_frame(&mut app, &ctx, vec![primary(handle, false)]); + // A Drop Shadow after it leaves the graphic where the frame places it: the handle shows. + push_effect(&mut app, 0, "stylize.dropShadow", json!({})); + assert!(handles_frame(&mut app, &ctx, vec![primary(handle, true)])); + handles_frame(&mut app, &ctx, vec![primary(handle, false)]); + // A second selected object with the same graphic and a Transform effect after its 3D effect: + // a drag would move its graphic too, so the artboard shows no handles. + let square = app.session.active().unwrap().selection.objects[0].0; + let other = app.run("shape.rectangle", json!({"x": 400, "y": 400, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0}})) + .unwrap(); + app.run("threeD.mapGraphic", json!({"symbol": "Graphic"})).unwrap(); + app.run("select.set", json!({"ids": [square, other]})).unwrap(); + assert!(handles_frame(&mut app, &ctx, vec![primary(handle, true)])); + handles_frame(&mut app, &ctx, vec![primary(handle, false)]); + push_effect(&mut app, 1, "distort.transform", json!({"moveH": 40.0})); + assert!(!handles_frame(&mut app, &ctx, vec![primary(handle, true)])); + handles_frame(&mut app, &ctx, vec![primary(handle, false)]); +} + +#[test] +fn a_press_that_closes_the_context_menu_does_not_grab_a_graphics_handle() { + let (mut app, ctx) = materials_app(); + app.run("threeD.mapGraphic", json!({"symbol": "Graphic"})).unwrap(); + let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 600.0)); + // One frame of the canvas's input with a context menu: whether the menu is open after it. + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut open = false; + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + crate::panels::set_pstate(ui.ctx(), super::DRAWN, Some(super::root_pass(ui.ctx()))); + let resp = ui.interact(rect, egui::Id::unique("three-d-test-canvas"), egui::Sense::click_and_drag()); + crate::canvas::canvas_input(app, ui, &resp, rect); + resp.context_menu(|ui| { + ui.label("Menu"); + }); + open = resp.context_menu_opened(); + }) + .textures_delta + .clear(); + open + }; + // A right-click below and to the right of the handle opens the menu there, clear of the handle. + let (handle, menu) = (handles_screen(&app, 150.0, 150.0), handles_screen(&app, 195.0, 195.0)); + let secondary = + |pressed| egui::Event::PointerButton { pos: menu, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }; + frame(&mut app, vec![egui::Event::PointerMoved(menu)]); + frame(&mut app, vec![secondary(true)]); + frame(&mut app, vec![secondary(false)]); + assert!(frame(&mut app, vec![egui::Event::PointerMoved(handle)])); + // The press on the handle only closes the menu: no drag of the graphic starts. + let (history, before) = (undo_len(&app), graphics(&app)); + assert!(!frame(&mut app, vec![primary(handle, true)])); + assert!(ctx.data(|m| m.get_temp::(super::handle_drag_id())).is_none()); + frame(&mut app, vec![egui::Event::PointerMoved(handle + egui::vec2(20.0, 0.0))]); + frame(&mut app, vec![primary(handle + egui::vec2(20.0, 0.0), false)]); + assert_eq!(undo_len(&app), history); + assert_eq!(graphics(&app), before); +} + +#[test] +fn a_click_on_a_graphics_handle_records_no_undo_step() { + let (mut app, ctx) = materials_app(); + app.run("threeD.mapGraphic", json!({"symbol": "Graphic", "x": 5.03})).unwrap(); + let history = undo_len(&app); + let handle = handles_screen(&app, 155.03, 150.0); + handles_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(handle)]); + assert!(handles_frame(&mut app, &ctx, vec![primary(handle, true)])); + assert!(handles_frame(&mut app, &ctx, vec![primary(handle, false)])); + assert_eq!(undo_len(&app), history); + assert_eq!(graphics(&app)[0]["x"], json!(5.03)); +} + +#[test] +fn a_selection_change_during_a_handle_drag_ends_it() { + let (mut app, ctx) = materials_app(); + app.run("threeD.mapGraphic", json!({"symbol": "Graphic"})).unwrap(); + let square = app.session.active().unwrap().selection.objects[0].0; + // A second object with the same graphic. + let other = app.run("shape.rectangle", json!({"x": 400, "y": 400, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0}})) + .unwrap(); + app.run("threeD.mapGraphic", json!({"symbol": "Graphic"})).unwrap(); + app.run("select.set", json!({"ids": [square]})).unwrap(); + let graphic_of = |app: &VectorcraftApp, id: u64| { + let n = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap(); + n.appearance.effects[0].params["graphics"][0].clone() + }; + let before = graphic_of(&app, other); + let (handle, target, further) = (handles_screen(&app, 150.0, 150.0), handles_screen(&app, 170.0, 140.0), handles_screen(&app, 190.0, 130.0)); + handles_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(handle)]); + assert!(handles_frame(&mut app, &ctx, vec![primary(handle, true)])); + handles_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(target)]); + // The selection changes to the other object while the pointer is down: the drag ends there. + app.run("select.set", json!({"ids": [other]})).unwrap(); + handles_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(further)]); + handles_frame(&mut app, &ctx, vec![primary(target, false)]); + assert_eq!(graphic_of(&app, other), before, "the other object's graphic moved"); + let moved = graphic_of(&app, square); + assert!((moved["x"].as_f64().unwrap() - 20.0).abs() < 0.11, "{moved}"); +} + /// Quick Actions › Expand as Wireframes shows for every object type, and turns the selected 3D /// object into a group of stroked edges in one undo step. #[test] diff --git a/crates/ui-egui/src/panels/three_d.rs b/crates/ui-egui/src/panels/three_d.rs index c9f2f2205e..805abbd51f 100644 --- a/crates/ui-egui/src/panels/three_d.rs +++ b/crates/ui-egui/src/panels/three_d.rs @@ -1,8 +1,10 @@ //! Window › 3D and Materials: edits the selection's 3D effect in place. The object types (Plane, //! Extrude, Revolve, Inflate) apply a 3D effect to a selection without one (`effect.apply`) or //! change the applied one (`effect.setParams` with `threeDType`). Below them are the 3D effect -//! dialogs' own Object, Rotation and Lighting controls; each change runs `effect.setParams` as one -//! undo step, and a drag previews and commits once, when the pointer is released. +//! dialogs' own Object, Rotation and Lighting controls and the panel's Materials tab +//! ([`materials`]); each change runs `effect.setParams` as one undo step, and a drag previews and +//! commits once, when the pointer is released. While the Materials tab is open, the artboard shows +//! a handle on each mapped graphic, which drags it over its face. use egui::Ui; use serde_json::{Map, Value, json}; @@ -11,6 +13,7 @@ use vectorcraft_effects::{CLASSIC_EXTRUDE, CLASSIC_REVOLVE, EXTRUDE, INFLATE, TH use super::{live_run, pstate, set_pstate}; use crate::VectorcraftApp; +use crate::dialogs::three_d_materials as materials; use crate::state::Dialog; use crate::theme::Tokens; use crate::widgets::{self, Live, dim_label}; @@ -221,6 +224,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // The selection changed during a drag: the drag ends with what it previewed. finish(app, &ctx); dim_label(ui, tl!("Select objects, then pick an object type to give them a 3D effect.")); + // The Graphics stay at hand, so art can be added before the 3D object is picked. + ui.add_space(10.0); + if let Some(action) = materials::library(app, ui, false) { + run_action(app, &ctx, action); + } return; }; // The applied effect's settings in the dialog's form, with the controls' own state. @@ -232,6 +240,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let mut d = Dialog { kind: ID.into(), fields }; let before = crate::dialogs::form::params(&d); crate::dialogs::revolve::controls(app, ui, &mut d); + let action = d.fields.remove(materials::ACTION).and_then(|v| materials::Action::from_value(&v)); let state: Map = d .fields .iter() @@ -255,6 +264,188 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } } + if let Some(action) = action { + run_action(app, &ctx, action); + } +} + +/// Runs what a click in the Materials tab's Graphics asked for: maps the graphic onto the selected +/// objects, its row then selected, or keeps art as a new graphic. An error goes to the status bar. +fn run_action(app: &mut VectorcraftApp, ctx: &egui::Context, action: materials::Action) { + finish(app, ctx); + let result = match &action { + materials::Action::Map(name) => app.run("threeD.mapGraphic", json!({"symbol": name})), + materials::Action::Add(Some(ids)) => app.run("threeD.addGraphic", json!({"ids": ids})), + materials::Action::Add(None) => app.run("threeD.addGraphic", json!({})), + }; + match result { + Ok(_) => { + if let materials::Action::Map(_) = action + && let Some(t) = target(app) + { + let count = vectorcraft_effects::mapped_graphics(&vectorcraft_effects::merged_params(&t.effect, &t.params)).len(); + set_selected_graphic(ctx, count.saturating_sub(1)); + } + } + Err(e) => app.ui.status = e, + } +} + +/// Makes the mapped graphic at `index` the one the Materials tab edits. +fn set_selected_graphic(ctx: &egui::Context, index: usize) { + let mut state: Map = pstate(ctx, UI_STATE); + state.insert(materials::SELECTED.into(), json!(index)); + set_pstate(ctx, UI_STATE, state); +} + +/// Is the panel showing its Materials tab? It was drawn in this pass or the last one, so a canvas +/// drawn before the panel in the same pass still sees it. +fn materials_open(ctx: &egui::Context) -> bool { + let drawn: Option = pstate(ctx, DRAWN); + let state: Map = pstate(ctx, UI_STATE); + drawn.is_some_and(|pass| pass + 1 >= root_pass(ctx)) + && state.get("__tab").and_then(Value::as_str) == Some("materials") + && state.get("__gizmo").and_then(Value::as_str) != Some("light") +} + +/// What [`graphic_handles`] gives: the object's frame, its graphics, and each seen graphic's index +/// and page point. +type Handles = (vectorcraft_effects::GraphicFrame, Vec, Vec<(usize, vectorcraft_geom::Point)>); + +/// The handles of the graphics mapped onto the first selected object, while the Materials tab is +/// open: each graphic's index, its face and where its center lands on the page, for the graphics +/// whose face the viewer sees, and none while a geometry effect follows a selected object's 3D effect. +/// With them, the object's [`vectorcraft_effects::GraphicFrame`] and the +/// graphics. +fn graphic_handles(app: &VectorcraftApp, ctx: &egui::Context) -> Option { + if !materials_open(ctx) { + return None; + } + let st = app.session.active()?; + let n = st.selection.objects.first().and_then(|id| st.doc.node(*id))?; + let t = find(n)?; + // The frame of the effect whose graphics are listed: none while that effect is hidden. + let frame = vectorcraft_effects::graphic_frame_at(Some(&st.doc), n, t.item, t.index)?; + // A geometry effect after the 3D effect of any selected object moves or bends its drawn + // graphics away from their places in the frame: no handles, so a drag does not put a graphic + // away from the pointer. + let reshaped = |n: &Node| find(n).is_some_and(|t| vectorcraft_effects::graphics_reshaped_after(n, t.item, t.index)); + if st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).any(reshaped) { + return None; + } + let list = vectorcraft_effects::mapped_graphics(&vectorcraft_effects::merged_params(&t.effect, &t.params)); + // Selected objects with different graphics show no handles: a drag sends the whole list. + let graphics_of = |t: Target| vectorcraft_effects::mapped_graphics(&vectorcraft_effects::merged_params(&t.effect, &t.params)); + if st.selection.objects.iter().skip(1).filter_map(|id| find(st.doc.node(*id)?)).any(|o| graphics_of(o) != list) { + return None; + } + let handles = list.iter().enumerate().filter_map(|(i, g)| frame.handle(g).filter(|(_, seen)| *seen).map(|(p, _)| (i, p))).collect(); + Some((frame, list, handles)) +} + +/// Temp-memory key of a drag of a graphic's handle on the artboard. +fn handle_drag_id() -> egui::Id { + egui::Id::unique("three-d-graphic-handle-drag") +} + +/// A drag of graphic `index`'s handle: its face, the page offset from the handle to the point +/// where it was grabbed, and the document and selected objects it was grabbed for. +#[derive(Clone, Debug)] +struct HandleDrag { + index: usize, + face: vectorcraft_effects::PlanarFace, + grab: vectorcraft_geom::Vec2, + owner: (u64, Vec), +} + +/// The active document's uid and its selected objects' ids: what a handle drag moves graphics for. +fn drag_owner(app: &VectorcraftApp) -> (u64, Vec) { + app.session.active().map(|st| (st.uid, st.selection.objects.iter().map(|id| id.0).collect())).unwrap_or_default() +} + +/// The offset, rounded to 0.1 pt, that a handle drag to offset (`x`, `y`) gives graphic `g`. `None` +/// when the graphic stays where it is: the pointer is where the handle was grabbed, or the rounded +/// offset is the graphic's own, so a click on a handle records no undo step. +fn moved_to(g: &vectorcraft_effects::MappedGraphic, x: f64, y: f64) -> Option<(f64, f64)> { + let rounded = ((x * 10.0).round() / 10.0, (y * 10.0).round() / 10.0); + let still = (x - g.x).abs() < 1e-6 && (y - g.y).abs() < 1e-6; + (!still && rounded != (g.x, g.y)).then_some(rounded) +} + +/// How near a press must be to a graphic's handle to grab it, in screen points. +const HANDLE_REACH: f32 = 8.0; + +/// The artboard's handles of the mapped graphics while the Materials tab is open: a press on one +/// grabs it, and dragging moves that graphic over its face, previewed while the pointer is down and +/// committed as one undo step when it is released. Returns whether the handles took the pointer. +pub(crate) fn graphic_handles_input(app: &mut VectorcraftApp, ui: &egui::Ui, resp: &egui::Response, rect: egui::Rect) -> bool { + let ctx = ui.ctx().clone(); + let xf = &crate::canvas::Xf::at(rect, app.view().unwrap_or(&crate::state::View::default()), app.canvas_ui_zoom); + let pointer = ui.input(|i| i.pointer.interact_pos()); + if let Some(drag) = ctx.data(|m| m.get_temp::(handle_drag_id())) { + if drag_owner(app) != drag.owner { + // The selection or the document changed during the drag: commit the preview and end + // the drag, so its grab offset does not move another object's graphic. + send(app, &ctx, Map::new(), false); + ctx.data_mut(|m| m.remove::(handle_drag_id())); + return true; + } + let down = ui.input(|i| i.pointer.primary_down()); + if let (Some(p), Some((frame, mut list, _))) = (pointer, graphic_handles(app, &ctx)) + && let Some((x, y)) = frame.offset_at(drag.face, xf.to_doc(p) - drag.grab) + && let Some(g) = list.get_mut(drag.index) + && let Some((x, y)) = moved_to(g, x, y) + { + (g.x, g.y) = (x, y); + let graphics = Value::Array(list.iter().map(vectorcraft_effects::MappedGraphic::to_value).collect()); + let mut changed = Map::new(); + changed.insert(vectorcraft_effects::GRAPHICS.into(), graphics); + send(app, &ctx, changed, down); + } else if !down { + send(app, &ctx, Map::new(), false); + } + if !down { + ctx.data_mut(|m| m.remove::(handle_drag_id())); + } + return true; + } + if !(resp.hovered() && ui.input(|i| i.pointer.primary_pressed())) { + return false; + } + // A press with Space held, or with the Hand, Zoom or Rotate View tool, pans, zooms or turns the + // view, as on any other part of the canvas. + let space = ui.input(|i| i.key_down(egui::Key::Space)); + if space || matches!(app.session.tool_id(), "hand" | "zoom" | "rotateView") { + return false; + } + let (Some(p), Some((_, list, handles))) = (pointer, graphic_handles(app, &ctx)) else { return false }; + let nearest = handles + .iter() + .map(|(i, at)| (*i, *at, xf.to_screen(*at).distance(p))) + .filter(|(.., d)| *d <= HANDLE_REACH) + .min_by(|a, b| a.2.total_cmp(&b.2)); + let Some((index, at, _)) = nearest else { return false }; + let Some(face) = list.get(index).map(|g| g.face) else { return false }; + let owner = drag_owner(app); + ctx.data_mut(|m| m.insert_temp(handle_drag_id(), HandleDrag { index, face, grab: xf.to_doc(p) - at, owner })); + set_selected_graphic(&ctx, index); + true +} + +/// Draws the mapped graphics' handles on the artboard while the Materials tab is open: a blue dot +/// at each graphic's center, larger for the one the tab edits. +pub(crate) fn paint_graphic_handles(app: &VectorcraftApp, painter: &egui::Painter, xf: &crate::canvas::Xf) { + let ctx = painter.ctx(); + let Some((_, _, handles)) = graphic_handles(app, ctx) else { return }; + let state: Map = pstate(ctx, UI_STATE); + let selected = state.get(materials::SELECTED).and_then(Value::as_u64).map(|i| i as usize); + let t = crate::theme::Tokens::get(ctx); + for (i, at) in handles { + let r = if Some(i) == selected { 6.0 } else { 4.5 }; + let c = xf.to_screen(at); + painter.circle_filled(c, r, t.accent); + painter.circle_stroke(c, r, egui::Stroke::new(1.5, egui::Color32::WHITE)); + } } /// Does the panel show its Quick Actions? On the Object tab of the effects that have one, and on diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index f55db4f5e7..60e651ae79 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -699,6 +699,10 @@ pub struct UiState { /// recent colors keeps filling while the row is hidden. #[serde(default = "yes", deserialize_with = "crate::panels::color::deserialize_show_recent_colors")] pub color_show_recent_colors: bool, + /// Swatches panel: Show Recent Colors in its panel menu, which draws the panel's Recent Colors + /// row (#6617; read as [`Self::color_show_recent_colors`] is). Separate from the Color panel's. + #[serde(default = "yes", deserialize_with = "crate::panels::color::deserialize_show_recent_colors")] + pub swatches_show_recent_colors: bool, /// The SVG Options chosen last (`svg` object of the export/save commands; null: never used). #[serde(default)] pub svg_options: Value, @@ -1148,6 +1152,7 @@ impl Default for UiState { color_mode: None, color_hide_options: false, color_show_recent_colors: true, + swatches_show_recent_colors: true, svg_options: Value::Null, place_link: true, dialog_file: None, diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 3465001072..b1424a4017 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -180,7 +180,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, 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)); no materials, soft shadows or ray tracing | +| 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 ([three-d.md](three-d.md#graphics)); no base materials, graphics on curved surfaces, 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 | | 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 0a6d0cf4b9..0dd9f958b3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3428,6 +3428,30 @@ checked as that effect's. It is one undo step. Any other `threeDType` value, or {"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"threeDType":"inflate","params":{"volume":70}}}} ``` +## 3D graphics + +`threeD.addGraphic {name?, ids?|id?}` keeps the selected art as a new document symbol (default name `Graphic`, made +unique) and leaves the art as it is: the 3D and Materials panel's Add as Single Graphic. It returns `{name}`. A +`name` that is not a string or is empty, or any other key, is refused. + +`threeD.mapGraphic {symbol, face?, x?, y?, scale?, rotation?, index?, item?, ids?|id?}` adds a graphic to the 3D +effect of each selected object, in one undo step: `face` is `front` (default) or `back`, `x` and `y` are the +offset in points of the graphic's center from the center of the object's bounds in the face's plane, `scale` is a +percentage of the symbol's size (1..10000; omitted, 100 or less so the graphic fits inside the object) and +`rotation` is in degrees counterclockwise (-360..360). Without `index` each object's first 3D effect takes it, and `item` is refused. +Every object's 3D effect must be Extrude & Bevel with Cap on (front only at a depth of 0) or Rotate (the Plane type, +front only), and `symbol` must name a document symbol; otherwise the command is refused and nothing changes. It returns `{ids, graphic}`. + +The graphics are the `graphics` parameter of `threeD.extrude` and `threeD.rotate`, which `effect.setParams` also +edits: an array of up to 64 `{symbol, face, x, y, scale, rotation}` objects, drawn on the face turned, +foreshortened and shaded with it and clipped to it ([Map artworks on 3D object](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/map-artwork-on-3d-objects.html)). +A graphic naming a symbol the document does not have is refused, unless the effect already holds a graphic of it. +The drawing rules are VectorCraft's own; see [three-d.md](three-d.md#graphics). + +```json +{"name":"run_command","arguments":{"command":"threeD.mapGraphic","params":{"symbol":"Graphic","face":"front","x":10,"scale":80,"rotation":15}}} +``` + ## Expand as Wireframes `effect.expandAsWireframes {ids?}` (**Window › 3D and Materials** › Quick Actions › Expand as Wireframes) replaces @@ -6598,7 +6622,7 @@ paint or width, guides, paths with no segment, type that draws no characters, a shape left out; with `selectionOnly`, the brushes the selected objects' strokes use), `brushes` (`[{name, type}]`: those brushes with their types, `calligraphic`, `scatter`, `art`, `pattern` or `bristle`, and `null` for a brush the Brushes panel does not hold; the brushes category's rows give each -name with its type, such as `Art brush`, or `missing from the Brushes panel`), `panelBrushNames` (the Brushes panel's brushes, whether art uses them or not), `repeatObjects` (`[{id, name, type, instances, rows?, cols?}]`: every Repeat object, those in +name with its type, such as `Art Brush`, or `missing from the Brushes panel`, in English; the Document Info panel shows them in the interface language), `panelBrushNames` (the Brushes panel's brushes, whether art uses them or not), `repeatObjects` (`[{id, name, type, instances, rows?, cols?}]`: every Repeat object, those in groups and in other repeats included, with its type `radial`, `grid` or `mirror` and the number of instances it makes, and a grid's `rows` and `cols`; with `selectionOnly`, the Repeat objects in the selection), `patternObjects` (`[{id, name, patterns}]`: every object filled or stroked with a pattern, once each, those in groups and type objects diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 85ac311de8..4aa7e8da42 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (3D: the seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins, so white-light 3D output is expected to differ from the 2026-10-17 comparison at facet corners (not measured: the scenes were not exported again); previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (3D: the seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins, so white-light 3D output is expected to differ from the 2026-10-17 comparison at facet corners (not measured: the scenes were not exported again); previously minor: #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -53,6 +53,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | Swatches panel: its menu's Show Recent Colors hides and shows the panel's Recent Colors row, checked by default and kept between sessions apart from the Color panel's (#6617); scores unchanged. | | 2026-11-09 | minor | Stroke panel: Line Arrow, a 41st generated arrowhead: two arms of the head's weight (stroke weight × arrowhead scale, at least 0.25 pt: the line's weight at 100 % for lines of 0.25 pt or more) at right angles, a sharp tip and square-cut ends, twice as wide as the other heads; bounds and clicks follow each head's own reach (`stroke.set` `LineArrow`, #5421); scores and estimates unchanged | | 2026-11-09 | minor | Canvas: a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve) changed on type or a group draws again where its art was and is, measured by that art; the canvas measures through its renderer and draws the part with the art it made to measure it. Exports and Object › Rasterize take the solid of 3D type. Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once. A 3D effect on a fill or stroke that builds nothing (Revolve after Roughen) no longer overflows the stack on the canvas, in Object › Rasterize and in raster and EPS exports: the fill or stroke draws as without it. Tests `tests::changed::a_3d_effect_changed_on_type_or_a_group_changes_where_its_solid_was`, `fx::tests::a_renderer_measures_the_art_on_each_artboard_building_each_solid_once`, `canvas::tests::an_edit_to_a_3d_effect_redraws_with_the_art_measured`, `fx::tests::a_3d_effect_that_builds_nothing_draws_the_item_as_without_it`. Scores and estimates unchanged. | | 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. | @@ -1354,6 +1355,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Plain Comma and Period, and also Shift+Comma and Shift+Period, apply the last solid color and the last gradient to the active Fill or Stroke proxy in VectorCraft ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), "Select color fill mode" and "Select gradient fill mode"). Recent Colors keeps the last 30 solid colors applied, newest first and each once, and the list stays the same when another document is opened or made active; a 31st color drops the oldest ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). The list lasts for the session. In the Color and Swatches panels the chips wrap onto further rows when the panel is too narrow for one row (`paint.recent`). Tests: `recent_colors_keep_thirty_colors_across_documents` in `crates/engine/src/tests/paintproxy.rs`, `all_thirty_recent_colors_wrap_into_a_narrow_panel` in `crates/ui-egui/src/panels/color/tests.rs`. Unchecking Show Recent Colors in the Color panel's menu hides its Recent Colors row, and checking it shows the row again ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). In VectorCraft the item is checked by default and kept between sessions, applied colors still join the list while the row is hidden, Hide Options hides the row either way, and the Swatches panel keeps its row (`ui.colorPanel {recentColors}`). Tests: `show_recent_colors_hides_and_shows_the_row` in `crates/ui-egui/src/panels/color/tests.rs`. + The Swatches panel's menu has its own Show Recent Colors item: unchecking it hides the panel's Recent Colors row and checking it shows the row again ([Color Enhancements in Illustrator](https://community.adobe.com/announcements-651/color-enhancements-in-illustrator-816797) puts recent colors in the Color and Swatches panels; [Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html) hides them with Show Recent Colors in the panel menu; a [community thread](https://community.adobe.com/questions-652/swatches-disappear-in-swatches-panel-when-switching-between-documents-in-29-7-1-on-macos-816823) turns it off from the Swatches panel). In VectorCraft it is checked by default, kept between sessions apart from the Color panel's, and applied colors still join the list while the row is hidden (#6617). Tests: `show_recent_colors_hides_and_shows_the_swatches_row` in `crates/ui-egui/src/panels/swatches/tests.rs`. A Recent Colors chip in the Color or Swatches panel drags into the Swatches panel's list to become a swatch of its color, in its color model, as one undo step ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). In VectorCraft a chip dropped on a color group or one of its swatches joins that group, anywhere else in the list it joins the ungrouped swatches at the end, a color that is already a swatch is added again under another name, and dragging a chip does not paint the selected art (`swatch.new`). Tests: `a_recent_color_dragged_onto_the_list_becomes_a_swatch` and `a_recent_color_dragged_from_the_color_panel_becomes_a_swatch` in `crates/ui-egui/src/panels/swatches/tests.rs`. A Cmd/Ctrl-click on the Color panel's spectrum applies Complement and a Cmd/Ctrl+Shift-click applies Invert to the active Fill or Stroke (`paint.complement`, `paint.invert`); with Alt also held, the click recolors the inactive one, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) list them. View › Proof Setup › Color blindness – Tritanopia-type turns Proof Colors on and proofs the canvas with the tritanopia simulation of Brettel, Viénot and Mollon (1997). The Separations Preview panel's Proof Setup list offers the same target (`view.proofSetup {target: "tritanopia"}`). Grays keep their value. diff --git a/docs/three-d.md b/docs/three-d.md index 6c23d31b8a..926f8fd40c 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -64,9 +64,9 @@ pages describe it. own choice. The group keeps the object's place, id, name, opacity, blending mode, opacity mask, attributes and text wrap and is selected; objects without a visible 3D effect do not change. It is one undo step, and an error changes nothing when no selected object has a visible 3D effect. -- The Lighting tab also holds the Shadows switch and its settings ([Shadows](#shadows)). The Materials tab and - ray-traced rendering are not built (see below). The rotation and light rings on the artboard are part of the - options dialog only. +- The Lighting tab also holds the Shadows switch and its settings ([Shadows](#shadows)). The Materials tab holds + the Graphics ([Graphics](#graphics)); base materials and ray-traced rendering are not built (see below). The + rotation and light rings on the artboard are part of the options dialog only. ## Extrude & Bevel @@ -320,6 +320,58 @@ Our reading: - Canvas, SVG and PDF exports draw the shadow, the art's bounds take it in, and Expand Appearance keeps it as a path below the faces. Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic) have no shadow. +## Graphics + +The public [Map artworks on 3D object](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/map-artwork-on-3d-objects.html) +page describes graphics: art kept with the document and placed on single surfaces of a 3D object, and the +[3D and Materials panel options](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/3d-materials-panel-options.html) +page lists Graphics and Add Materials and Graphics on the Materials tab. VectorCraft maps graphics onto planar faces: +Extrude & Bevel's front and back caps, and the plane of the Plane type (Rotate). + +- The panel's **Materials** tab shows the document's graphics as thumbnails. A graphic is a document symbol, as + the public page allows existing symbols to be used. **Add as Single Graphic** keeps the selected art as a new + symbol (`threeD.addGraphic`) and leaves the art as it is; art dragged from the artboard onto the thumbnails is + added the same way. The thumbnails and Add as Single Graphic show without a selected 3D object too. +- Clicking a thumbnail maps that graphic onto the front of the 3D effect of each selected object + (`threeD.mapGraphic`), in one undo step. A graphic larger than the object starts scaled down to fit inside it. +- Below the thumbnails the tab lists the graphics mapped onto the first selected object. The selected one shows + its Face (Front or Back; Plane has only a front), X and Y (the offset of its center from the center of the + object's bounds, in the face's plane), Scale (a percentage of the symbol's size) and Rotation + (counterclockwise), and **Remove Graphic** takes it off. Each change is one undo step. +- While the tab is open, a blue handle sits on the center of each mapped graphic whose face is turned toward the + viewer. Dragging a handle moves that graphic over its face, previewed while the pointer is down and recorded as + one undo step when it is released. The handles are hidden while the selected objects have different graphics, + and while a geometry effect (such as Transform) comes after the 3D effect. A press on the canvas while its + context menu is open only closes the menu and does not grab a handle. + +These rules are VectorCraft's own: + +- A graphic is drawn on the visible face of each of the object's solids (a fill's, and a stroke's outline), + turned, foreshortened by the perspective and twisted and tapered with the face, and clipped to it. It is shaded + with the face's light: each color is multiplied by the light that falls on the face, and drawn with the + opacity and blending mode of the fill or stroke the face is painted with. A graphic on a face turned away from + the viewer is not drawn. +- On the back cap, X runs to the right as the back is seen from behind, so the graphic reads the right way round + there. +- The symbol's fills and strokes are drawn as filled areas, a stroke as its outline, and their gradients and + patterns are placed with the graphic. Images, hidden objects, + guides and clipping paths in the symbol are left out, and clipping groups draw their art unclipped. Instances + of other symbols in it are drawn. Symbols may nest in its art up to 8 levels deep, and its objects may be nested + up to 128 levels deep; commands refuse a graphic past either limit, and a loaded file draws the object without it. +- Without perspective, the graphic maps exactly. With perspective, straight lines stay straight and each curve is + cut into 8 cubic pieces before it is mapped. The part of a graphic at or behind the eye is left out. +- An object takes up to 64 graphics. One graphic draws up to 4,096 fills and strokes, and the graphics of one 3D + effect draw up to 400,000 path elements together. Commands refuse a graphic past either limit. A loaded file + draws the object without a graphic past the first limit, and without all of its graphics past the second. +- A hollow Extrude & Bevel has no caps, so its graphics are not drawn until Cap is turned on again, and + `threeD.mapGraphic` refuses to map a new one onto it. Changing the type to Revolve or Inflate + drops the graphics, and changing between Extrude and Plane keeps them, except that Plane drops the graphics + on the back cap. +- `effect.apply` and `effect.setParams` refuse a graphic that names a symbol the document does not have, unless + the effect already holds a graphic of that symbol. A graphic whose symbol is deleted is no longer drawn. Renaming a symbol renames it in the graphics that use it, + in the layers, in the art of symbols, patterns, graph designs, brushes and opacity masks, and in graphic + styles, and Select All Unused in the Symbols panel counts a symbol a graphic uses as used, a graphic in a graphic style included. + ## Parameters (`effect.apply`, `effect.setParams`) `threeD.extrude`: `depth`, `twist`, `taper` (%), `cap`, `bevel` (`none`, `classic`, `rounded`, `cove`, `step`, @@ -328,6 +380,10 @@ Our reading: uses: `rotationX`, `rotationY`, `rotationZ`, `perspectiveAngle` (degrees), `shade`, `lightColor` (`#rrggbb`, default `#ffffff`), `lightIntensity` (%), `lightRotation`, `lightHeight` (degrees), `lightSoftness` (%), `ambient` (%, to 200), `lightBehind`. Lengths are points and scale with the object (Scale Strokes & Effects). Commands validate the shape and the parameters before the document changes. All four also take `lights`: null (the default, use the earlier single-light fields), or an array of one to ten objects. Each object takes `color` (opaque `#rrggbb`, white), `intensity` (0..100%, 80), `rotation` (-180..180 degrees, 125.26), `height` (0..90 degrees, 30), `softness` (0..100%, 0), and `behind` (boolean, false). Omitted settings use those defaults. An explicit list replaces the earlier single light; `ambient` and `shade` remain shared. Set `lights` to null to return to the single-light fields. Invalid lists, counts, keys and values are rejected before editing; malformed loaded lists fall back to the earlier light safely. All four take `shadow` (boolean, false), `shadowPosition` (`behind` or `below`), `shadowDistance` (0..100%, 0) and `shadowBounds` (10..400%, 100) ([Shadows](#shadows)); values out of range are rejected before editing. +`threeD.extrude` and `threeD.rotate` take `graphics` ([Graphics](#graphics)): an array of up to 64 objects, each +with `symbol` (the name of a document symbol), `face` (`front` or `back`, default `front`; `threeD.rotate` takes +`front` only), `x` and `y` (points, default 0), `scale` (1..10000%, default 100) and `rotation` (-360..360 degrees, +default 0). Other keys, values out of range and graphics on the other 3D effects are rejected before editing. ## What differs from the public documentation @@ -336,10 +392,11 @@ and [Create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effe pages, and the archived Extrude & Bevel option list (depth 0–2,000, cap on/off, bevel height, bevel extent in or out, perspective). No Adobe software, assets, presets or bevel shapes were used. -- **Materials** (base, Adobe Substance and graphics), soft shadows, shadows of lights after the first, - and **ray-traced rendering** are not built: shading uses directional lights, in the paint of the fill or - stroke the solid is built for ([Fills and strokes](#fills-and-strokes)), and the shadow is hard-edged - ([Shadows](#shadows)). +- **Materials** (base and Adobe Substance), graphics on curved surfaces (Revolve, Inflate and Extrude & Bevel's + walls and bevels), soft shadows, shadows of lights after the first, and **ray-traced rendering** are not built: + shading uses directional lights, in the paint of the fill or stroke the solid is built for + ([Fills and strokes](#fills-and-strokes)), and the shadow is hard-edged ([Shadows](#shadows)). Graphics map + onto planar faces only ([Graphics](#graphics)). - The bevel shapes and their names are our own; custom bevel paths are not supported. - Visibility is a painter's algorithm: faces of a closed solid turned away are left out, and the rest are drawn back to front, with a flat cap or flat back turned toward the viewer drawn after all of them. Concave solids