mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 05:24:44 +00:00
Merge remote-tracking branch 'upstream/main' into seam-round-joins
# Conflicts: # docs/effects-parity.md # docs/target-app-parity.md
This commit is contained in:
@@ -189,8 +189,8 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
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<EffectInfo> {
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
+289
-24
@@ -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<usize>) -> 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<Arc<Node>>, Vec<Arc<Node>>);
|
||||
/// 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<ShadowAndSurfaces> {
|
||||
let art = surfaces_for(n, fx, shapes)?;
|
||||
fn art_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<ShadowAndSurfaces> {
|
||||
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<Option<
|
||||
/// its member's scopes and ink ([`cast_shadow`]).
|
||||
type Cast<'a> = (vectorcraft_three_d::ShadowCaster, vectorcraft_three_d::Shadow, Vec<shadow::Cast<'a>>);
|
||||
|
||||
/// [`art_for`] without the shadow: the solids' surfaces.
|
||||
fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>> {
|
||||
/// 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<Node>)> {
|
||||
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<Facet>,
|
||||
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<Node>)> {
|
||||
let mut groups: Vec<Vec<&Filled>> = 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<Placed>, Node, &'s [Scope], Vec<Option<PlanarFace>>);
|
||||
|
||||
/// [`with_graphics`] of built surfaces, each drawn whole.
|
||||
fn whole_surfaces<'s>(surfaces: Vec<BuiltSurface<'s>>, arts: &[(usize, PlanarFace, Arc<Node>)]) -> Vec<(&'s [Scope], Arc<Node>)> {
|
||||
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<Node>)>, arts: &[(usize, PlanarFace, Arc<Node>)]) -> Vec<(&'s [Scope], Arc<Node>)> {
|
||||
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<Item = &Facet> {
|
||||
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<Vec<Arc<Node>>> {
|
||||
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<Vec<Arc<Nod
|
||||
let masks = has_masks(shapes);
|
||||
// The paint copies of every solid together: past the allowance the source path is drawn.
|
||||
let mut paint_bytes = 0usize;
|
||||
// The caps are copied only when the effect has graphics to draw on them.
|
||||
let graphics_on = !classic && takes_graphics(&fx.id) && !mapped_graphics(&p).is_empty();
|
||||
if is_plane(&fx.id) {
|
||||
// Every part turns about the centre of the object's own bounds (a group's members about
|
||||
// the centre of theirs), so they stay together.
|
||||
let mut out = vec![];
|
||||
for s in shapes {
|
||||
let mut filled = vec![];
|
||||
for (member, s) in shapes.iter().enumerate() {
|
||||
for item in &s.items {
|
||||
let (paint, opacity, blend) = paint_of(item, s.opacity);
|
||||
let (area, rule) = region(&s.path, s.rule, item);
|
||||
@@ -807,10 +963,18 @@ fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Nod
|
||||
if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, &paint, colored), &paint) {
|
||||
return None;
|
||||
}
|
||||
if graphics_on {
|
||||
let caps: Vec<Facet> = 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<Vec<Arc<Nod
|
||||
let (_, opacity, blend) = paint_of(item, *member);
|
||||
opacity >= 1.0 && blend == BlendMode::Normal
|
||||
});
|
||||
let surfaces: Vec<(Vec<Placed>, Node, &[Scope])> = solids
|
||||
let arts = if !graphics_on {
|
||||
vec![]
|
||||
} else {
|
||||
let filled: Vec<Filled> = solids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(at, (item, member, _, f, shape))| {
|
||||
let caps: Vec<Facet> = 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<BuiltSurface> = 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<Placed> = 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<Option<Box<OpacityMask>>> = 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<Option<Box<OpacityMask>>> = 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<Vec<Arc<Nod
|
||||
_ => extrude_options(&p).cap,
|
||||
};
|
||||
let order = draw_order(&placed, sealed);
|
||||
let faces: Vec<(usize, Arc<Node>)> = 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<Node>)> = 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<Option<usize>> =
|
||||
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<Node> {
|
||||
}
|
||||
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<Node> {
|
||||
let remaining: Vec<Effect>;
|
||||
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<Node> {
|
||||
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<Effect> = item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect();
|
||||
let with_later = |mut art: Arc<Node>| {
|
||||
Arc::make_mut(&mut art).appearance.effects = later.clone();
|
||||
@@ -1015,11 +1209,11 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
/// 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<Node> {
|
||||
fn group_solid_art(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
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<Node> {
|
||||
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<usize>) -> Option<graphics::GraphicFrame> {
|
||||
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<usize>, index: usize) -> Option<graphics::GraphicFrame> {
|
||||
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<usize>, 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<usize>, pick: &Pick<'_>) -> Option<graphics::GraphicFrame> {
|
||||
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<Rect> {
|
||||
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
|
||||
|
||||
@@ -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<Self, String> {
|
||||
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<f64, String> {
|
||||
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<MappedGraphic> {
|
||||
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<String> {
|
||||
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::<usize>();
|
||||
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<Vec<Piece>, String> {
|
||||
fn walk(doc: &Document, n: &Node, outer: f32, xf: Affine, (depth, level): (usize, usize), out: &mut Vec<Piece>) -> 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<Affine>, room: usize) -> Result<BezPath, NotMapped> {
|
||||
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<Node> {
|
||||
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<Rect> {
|
||||
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<FaceMap>,
|
||||
back: Option<FaceMap>,
|
||||
}
|
||||
|
||||
impl GraphicFrame {
|
||||
pub(super) fn new(bounds: Rect, front: Option<FaceMap>, back: Option<FaceMap>) -> 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<String> {
|
||||
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<String> {
|
||||
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<vectorcraft_doc::Effect>> = 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;
|
||||
@@ -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<Vec<(PathData, Paint)>> {
|
||||
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<Point> = [(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::<Vec<_>>(), [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<f64> = 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}");
|
||||
}
|
||||
@@ -208,7 +208,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
|
||||
@@ -115,6 +115,8 @@ fn art_symbols(n: &Node, out: &mut Vec<String>) {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
*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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -629,14 +629,15 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let font_names: Vec<String> = 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<Value> =
|
||||
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();
|
||||
|
||||
@@ -34,8 +34,15 @@ fn svg_filter_name(s: &Session, xml: &str, cmd: &str) -> Result<String> {
|
||||
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<CommandSpec> {
|
||||
let mut specs = effect_specs();
|
||||
specs.extend(graphics::specs());
|
||||
specs
|
||||
}
|
||||
|
||||
fn effect_specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"effect.apply",
|
||||
@@ -454,6 +461,9 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
mod graphics;
|
||||
|
||||
#[cfg(test)]
|
||||
mod lighting_presets_tests;
|
||||
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
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<Value> {
|
||||
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>, 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<Value> {
|
||||
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>, 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;
|
||||
@@ -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<String> =
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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<PathData, PathOpsError> {
|
||||
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<W: Fn(kurbo::Point, f64) -> bool>(
|
||||
path: &PathData,
|
||||
delta: f64,
|
||||
join: Join,
|
||||
miter_limit: f64,
|
||||
within_of: impl Fn(&PathData) -> W,
|
||||
) -> Result<PathData, PathOpsError> {
|
||||
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<SubPath>, Vec<SubPath>) = 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<PathData, PathOpsError> {
|
||||
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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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> {
|
||||
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<PlanarFace>,
|
||||
/// The contours in view space, before projection, for tests that measure depths.
|
||||
#[cfg(test)]
|
||||
view: Vec<Vec<V3>>,
|
||||
@@ -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![],
|
||||
|
||||
@@ -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<f64>) {
|
||||
(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));
|
||||
|
||||
@@ -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<f64>,
|
||||
/// 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<Point> {
|
||||
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<Point> {
|
||||
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<Vec<Point>> {
|
||||
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<Affine> {
|
||||
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<FaceMap> {
|
||||
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<FaceMap> {
|
||||
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;
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Unit>) {
|
||||
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));
|
||||
|
||||
@@ -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<Vec<u64>>),
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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<Action> {
|
||||
heading(ui, tl!("Graphics"));
|
||||
let st = app.session.active()?;
|
||||
let names: Vec<String> = 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<MappedGraphic> = 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()));
|
||||
}
|
||||
@@ -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 Нова калиграфска четка
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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… Νέο επίπεδο…
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3356,6 +3356,7 @@ antiAlias Sharp शार्प
|
||||
Pattern पैटर्न
|
||||
Pattern Borders पैटर्न सीमा
|
||||
Pattern Brush पैटर्न ब्रश
|
||||
missing from the Brushes panel ब्रश पैनल में अनुपलब्ध
|
||||
Pattern Brush Options पैटर्न ब्रश ऑप्शन्स
|
||||
Pattern Editing पैटर्न संपादन
|
||||
Pattern Options पैटर्न ऑप्शन्स
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2403 and column 5.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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 新規絵筆ブラシ
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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 ახალი სიმბოლო
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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 새 캘리그래피 브러시
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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 Новая каллиграфическая кисть
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -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.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -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 Новий каліграфічний пензель
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -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
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 204 and column 5.
|
@@ -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 新建书法画笔
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -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 新增書法筆刷
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Event>) -> 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::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// 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::<crate::UiState>(prefs).unwrap().swatches_show_recent_colors
|
||||
};
|
||||
assert_eq!((saved(json!(false)), saved(json!(true)), saved(json!("no"))), (false, true, true));
|
||||
}
|
||||
|
||||
@@ -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<egui::Event>| {
|
||||
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<egui::Event>) -> 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<egui::Event>| {
|
||||
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::HandleDrag>(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]
|
||||
|
||||
@@ -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<String, Value> = 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<String, Value> = 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<u64> = pstate(ctx, DRAWN);
|
||||
let state: Map<String, Value> = 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<vectorcraft_effects::MappedGraphic>, 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<Handles> {
|
||||
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<u64>),
|
||||
}
|
||||
|
||||
/// The active document's uid and its selected objects' ids: what a handle drag moves graphics for.
|
||||
fn drag_owner(app: &VectorcraftApp) -> (u64, Vec<u64>) {
|
||||
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::<HandleDrag>(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::<HandleDrag>(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::<HandleDrag>(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<String, Value> = 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) |
|
||||
|
||||
+25
-1
@@ -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
|
||||
|
||||
File diff suppressed because one or more lines are too long
+64
-7
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user