Files
vectorcraft/crates/effects/src/solids.rs
T
dotCooCoo 27a0dc89d4 3D and Materials: Render Settings › Wireframe draws the live 3D effect as its faces' edges
The 3D and Materials panel gets a Render Settings menu with Wireframe.
Extrude & Bevel, Revolve, Inflate and Rotate take a `render` parameter,
`{mode: "shaded" | "wireframe"}`. In wireframe mode the effect stays
live and draws the edges Expand as Wireframes gives, each once, as
0.5 pt black strokes with round joins, with no shadow or graphics. The
object's own opacity, blending mode and opacity mask apply to the mesh.

Commands that set wireframe mode are checked against the edges it
draws: the shaded surface's paint copies, graphics and shadow limits do
not refuse them. A `render` value that is not an object holding only a
valid `mode`, and `render` on the 3D (Classic) effects, are refused.

Docs: docs/three-d.md, docs/mcp.md, docs/effects-parity.md,
docs/target-app-parity.md.
2026-11-14 21:04:33 -08:00

2479 lines
120 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Extrude & Bevel, Inflate, Rotate, Extrude & Bevel (Classic) and Rotate (Classic) as live, shaded vector art,
//! shared by the canvas and every exporter. Like Revolve, the source path stays editable and the effect draws its 3D faces.
use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::color::{BlendMode, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask};
use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Vec2};
use vectorcraft_pathops::BoolOp;
use vectorcraft_three_d::{
Bevel, BevelShape, DepthPlane, Extrude, Facet, Inflate, MAX_OBJECT_FACES, Mesh, MeshedSolid, PlanarFace, SolidFaces, View,
};
#[cfg(test)]
use vectorcraft_doc::color::Color;
use crate::util::{flag, num, text};
use crate::{GeomContext, apply_geometry_with, is_geometry};
pub(crate) mod classic;
pub use classic::{CLASSIC_EXTRUDE, CLASSIC_ROTATE, ROTATE_SURFACES, SHADING_COLORS, classic_extrude_options, classic_shading};
mod source;
pub(crate) use source::{resolve, resolve_for};
pub(crate) 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, NOT_TAKEN as GRAPHICS_NOT_TAKEN, SCALE_RANGE as GRAPHIC_SCALE_RANGE, appearance_graphic_symbols,
rename_appearance_graphic_symbol,
};
pub(crate) mod shadow;
pub(crate) mod wireframe;
pub use wireframe::{RENDER, RENDER_MODES, renders_wireframe, three_d_wireframe_in};
pub const EXTRUDE: &str = "threeD.extrude";
pub const INFLATE: &str = "threeD.inflate";
pub const ROTATE: &str = "threeD.rotate";
/// The 3D effects this module draws (Revolve has its own).
pub const SOLIDS: [&str; 5] = [EXTRUDE, INFLATE, ROTATE, CLASSIC_EXTRUDE, CLASSIC_ROTATE];
/// Is `id` one of the 3D and Materials effects (Revolve and Revolve (Classic) included)?
pub fn is_three_d(id: &str) -> bool {
crate::revolve::is_revolve(id) || SOLIDS.contains(&id)
}
/// Is `id` one of the 3D (Classic) effects: Extrude & Bevel (Classic), Revolve (Classic) or Rotate
/// (Classic)?
pub fn is_classic_three_d(id: &str) -> bool {
id == CLASSIC_EXTRUDE || id == crate::CLASSIC_REVOLVE || id == CLASSIC_ROTATE
}
/// Does 3D effect `id` turn the art as a flat plane: Rotate or Rotate (Classic)?
fn is_plane(id: &str) -> bool {
id == ROTATE || id == CLASSIC_ROTATE
}
/// The 3D and Materials panel's object types and the effect that draws each: (type, effect id).
/// Plane keeps the art flat, which is what Rotate draws.
pub const THREE_D_TYPES: [(&str, &str); 4] = [("plane", ROTATE), ("extrude", EXTRUDE), ("revolve", crate::REVOLVE), ("inflate", INFLATE)];
/// The parameters of 3D effect `to` for an effect `from` with `params`, as switching the object
/// type keeps them: every setting `to` takes keeps its value from `params` (the defaults of `from`
/// under them), each rotation angle wrapped into -180° to 180° when `to` is not Revolve, and the
/// settings only `to` takes start at their defaults. Settings `to` does not take are dropped.
pub fn three_d_params_as(from: &str, params: &Value, to: &str) -> Value {
let current = crate::merged_params(from, params);
let mut out = crate::default_params(to).and_then(|v| v.as_object().cloned()).unwrap_or_default();
for (key, value) in out.iter_mut() {
if let Some(v) = current.get(key) {
value.clone_from(v);
}
}
if to != crate::REVOLVE {
for key in ["rotationX", "rotationY", "rotationZ"] {
if let Some(a) = out.get(key).and_then(Value::as_f64).filter(|a| !(-180.0..=180.0).contains(a)) {
out.insert(key.into(), serde_json::json!((a + 180.0).rem_euclid(360.0) - 180.0));
}
}
}
// A plane has only a front, and Revolve's graphics go on its side, named the front, so graphics
// on the back cap are dropped.
if (to == ROTATE || to == crate::REVOLVE)
&& 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)
}
fn solid(fx: &[Effect]) -> Option<(usize, &Effect)> {
fx.iter().enumerate().find(|(_, e)| e.visible && SOLIDS.contains(&e.id.as_str()))
}
/// Does `n` carry a visible Extrude & Bevel, Inflate or Rotate effect?
pub fn has_solid(n: &Node) -> bool {
solid(&n.appearance.effects).is_some() || n.appearance.items.iter().any(|i| i.visible() && solid(i.effects()).is_some())
}
/// The Position presets: (id, [rotation X, Y, Z]). Front, Back, Left, Right, Top and Bottom face
/// the art that way; Off-Axis tilts each 20° down and 25° aside (showing the top and right);
/// Isometric lays the art on the left, right, top or bottom plane of an isometric view (the
/// visible axes foreshortened equally: atan(1/√2) ≈ 35.26° and 45°). Our own values, from the
/// geometry of each view.
pub const ROTATION_PRESETS: [(&str, [f64; 3]); 16] = [
("front", [0.0, 0.0, 0.0]),
("back", [0.0, 180.0, 0.0]),
("left", [0.0, 90.0, 0.0]),
("right", [0.0, -90.0, 0.0]),
("top", [-90.0, 0.0, 0.0]),
("bottom", [90.0, 0.0, 0.0]),
("offAxisFront", [-21.88, -23.4, 9.06]),
("offAxisBack", [-158.12, 23.4, -170.94]),
("offAxisLeft", [-40.74, 58.39, -36.26]),
("offAxisRight", [-139.26, -58.39, 143.74]),
("offAxisTop", [-111.88, -23.4, 9.06]),
("offAxisBottom", [68.12, -23.4, 9.06]),
("isometricLeft", [-45.0, -35.26, 30.0]),
("isometricRight", [-45.0, 35.26, -30.0]),
("isometricTop", [45.0, -35.26, 30.0]),
("isometricBottom", [-45.0, -35.26, -30.0]),
];
/// The view parameters every 3D effect shares, named as Revolve's.
pub fn view_options(p: &Value) -> View {
View {
rotation_x: num(p, "rotationX", ROTATION_PRESETS[6].1[0]),
rotation_y: num(p, "rotationY", ROTATION_PRESETS[6].1[1]),
rotation_z: num(p, "rotationZ", ROTATION_PRESETS[6].1[2]),
perspective: num(p, "perspectiveAngle", 0.0),
light_rotation: num(p, "lightRotation", vectorcraft_three_d::DEFAULT_LIGHT.0),
light_height: num(p, "lightHeight", vectorcraft_three_d::DEFAULT_LIGHT.1),
light_behind: flag(p, "lightBehind", false),
light_color: crate::lighting::light_color(p),
lights: crate::lighting::lights(p),
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
light_softness: num(p, "lightSoftness", 0.0) / 100.0,
ambient: num(p, "ambient", 25.0) / 100.0,
shade: flag(p, "shade", true),
}
}
/// Extrude & Bevel's parameters. `bevel` is `"none"` or a [`BevelShape`] id.
pub fn extrude_options(p: &Value) -> Extrude {
Extrude {
depth: num(p, "depth", 50.0),
twist: num(p, "twist", 0.0),
taper: num(p, "taper", 100.0) / 100.0,
cap: flag(p, "cap", true),
bevel: BevelShape::parse(text(p, "bevel", "none")).map(|shape| Bevel {
shape,
width: num(p, "bevelWidth", 4.0),
height: num(p, "bevelHeight", 4.0),
repeat: num(p, "bevelRepeat", 1.0).clamp(1.0, 32.0) as usize,
space: num(p, "bevelSpace", 0.0) / 100.0,
inside: flag(p, "bevelInside", false),
both_sides: flag(p, "bevelBothSides", false),
}),
hidden: false,
}
}
pub fn inflate_options(p: &Value) -> Inflate {
Inflate {
depth: num(p, "depth", 0.0),
volume: num(p, "volume", 50.0),
both_sides: flag(p, "bothSides", false),
twist: num(p, "twist", 0.0),
taper: num(p, "taper", 100.0) / 100.0,
cap: flag(p, "cap", true),
}
}
/// The shape a solid is made from: a path's or a compound path's outline.
pub(crate) fn profile(n: &Node) -> Option<(PathData, FillRule)> {
match &n.kind {
NodeKind::Path { path, rule, guide: false, 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))
}
// Type extrudes as its glyph outlines and stays type: editing it reshapes the solid (#4715).
NodeKind::Text(_) => {
let mut subpaths = vec![];
crate::outline_text(n)?.walk(&mut |c| subpaths.extend(c.path_data().into_iter().flat_map(|p| p.subpaths.iter().cloned())));
(!subpaths.is_empty()).then(|| (PathData::new(subpaths), FillRule::NonZero))
}
_ => None,
}
}
/// What a 3D effect paints `n` with: its own fills and strokes, else, for type that has none, the
/// fill and stroke of its first painted run (its characters' colours).
pub(crate) fn solid_paints(n: &Node) -> Vec<AppearanceItem> {
let own: Vec<AppearanceItem> = n.appearance.items.iter().filter(|i| crate::paints(i)).cloned().collect();
if !own.is_empty() || !matches!(n.kind, NodeKind::Text(_)) {
return own;
}
let mut runs = vec![];
if let Some(outlined) = crate::outline_text(n) {
outlined.walk(&mut |c| {
if runs.is_empty() && c.path_data().is_some() {
runs = c.appearance.items.iter().filter(|i| crate::paints(i)).cloned().collect();
}
});
}
runs
}
/// [`solid_paints`] with, for each paint, the item of `n` it copies: the item a live-effect plug-in
/// before the paint's own 3D effect is told it sits on. The paints of a type's first run copy no
/// item of `n` and have none.
pub(crate) fn solid_paint_sources(n: &Node) -> (Vec<Option<&AppearanceItem>>, Vec<AppearanceItem>) {
let own: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| crate::paints(i)).collect();
if own.is_empty() {
let paints = solid_paints(n);
return (vec![None; paints.len()], paints);
}
let paints = own.iter().map(|i| (*i).clone()).collect();
(own.into_iter().map(Some).collect(), paints)
}
fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> {
#[cfg(test)]
if !is_plane(id) {
SOLID_GEOMETRY_BUILT.with(|n| n.set(n.get() + 1));
}
let view = view_options(p);
match id {
EXTRUDE => vectorcraft_three_d::extrude(path, rule, bounds, extrude_options(p), view),
CLASSIC_EXTRUDE => vectorcraft_three_d::extrude(path, rule, bounds, classic_extrude_options(p), view),
INFLATE => vectorcraft_three_d::inflate(path, rule, bounds, inflate_options(p), view),
_ => vectorcraft_three_d::plane(path, rule, bounds, view),
}
}
/// [`facets`], and with `cast` for Extrude & Bevel or Rotate the faces the solid's shadow is cast
/// from, from the same build. Inflate's solids are built together ([`built_solids_cast`]), and the
/// classic effects cast no shadow: they keep no faces.
fn facets_and_faces(
id: &str,
path: &PathData,
rule: FillRule,
bounds: Rect,
p: &Value,
cast: bool,
) -> Result<(Vec<Facet>, Option<SolidFaces>), String> {
let view = view_options(p);
#[cfg(test)]
if cast && id == EXTRUDE {
SOLID_GEOMETRY_BUILT.with(|n| n.set(n.get() + 1));
}
let built = match id {
EXTRUDE if cast => vectorcraft_three_d::extrude_shadowed(path, rule, bounds, extrude_options(p), view),
ROTATE if cast => vectorcraft_three_d::plane_shadowed(path, rule, bounds, view),
_ => return facets(id, path, rule, bounds, p).map(|f| (f, None)),
};
built.map(|(f, faces)| (f, Some(faces)))
}
/// Check solid `id`'s parameter values `p` alone: the part of [`validate_solid`] that does not
/// need the object.
fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> {
if id == CLASSIC_EXTRUDE {
classic::validate(p)?;
}
if id == CLASSIC_ROTATE {
classic::validate_rotate(p)?;
}
crate::lighting::validate(p)?;
shadow::validate(p)?;
graphics::validate(id, p)?;
wireframe::validate_render(id, p)?;
if let Some(v) = p.get("bevel")
&& !v.as_str().is_some_and(|s| s == "none" || BevelShape::parse(s).is_some())
{
let names: Vec<_> = BevelShape::ALL.iter().map(|b| b.id()).collect();
return Err(format!("bevel must be none or one of {}", names.join(", ")));
}
Ok(())
}
/// The name an error gives 3D effect `id`.
pub(crate) fn label_of(id: &str) -> &'static str {
crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…'))
}
/// Validate before a command changes the document; the evaluator bounds loaded files too.
pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
validate_solid_in(None, n, id, p)
}
/// [`validate_solid`] with the symbols of `doc`: a symbol instance, a blend and a clipping group
/// build from the art they show ([`resolve`]).
pub fn validate_solid_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> {
validate_solid_for(doc, n, id, p, (None, None))
}
/// [`validate_solid_in`] for the effect at `(item, index)`: index `index` of the effects of the
/// object (`item` `None`) or of its appearance item `item`, whose shadow is cast by that item's
/// solid alone. The index is the effects' length for a new effect. The solids are checked as
/// [`solid_art_in`] draws them: from the shapes after the geometry effects before the 3D effect.
/// On the object (or group) with `index` `None`, that effect is its first visible Extrude & Bevel,
/// Inflate or Rotate, as drawn, or a new one after its other effects when it has none.
fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: (Option<usize>, 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))?;
let effects = &n.appearance.effects;
let index = match at {
(None, Some(index)) => index,
_ => solid(effects).map_or(effects.len(), |(index, _)| index),
};
let before = effects.get(..index).unwrap_or(effects);
if is_container(n) {
let mut shapes = members(n, label)?;
reshape_members(n, before, &mut shapes);
let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?;
check_profiles(id, p, &shapes, b)?;
check_solids(id, p, &shapes)?;
return check_cast(id, p, &shapes);
}
let (path, rule) = object_profile(n, label)?;
let anchors = || format!("{label} requires a shape with at least two anchors");
path.bounds().ok_or_else(anchors)?;
let ctx = GeomContext::of(n);
// With its shadow on, the shadow of the object's solids (or of the item's) must fit too.
let (path, items) = match at.0.and_then(|i| Some((n.appearance.items.get(i)?, at.1.unwrap_or(usize::MAX)))) {
Some((item, index)) => {
let path = geometry(&path, effects, &ctx);
(geometry(&path, item.effects().get(..index).unwrap_or(item.effects()), &ctx.item(item)), vec![item.clone()])
}
None => (geometry(&path, before, &ctx), solid_paints(n)),
};
let b = path.bounds().ok_or_else(anchors)?;
let shapes = [Profile { path, rule, items, opacity: 1.0, scopes: vec![] }];
check_profiles(id, p, &shapes, b)?;
check_solids(id, p, &shapes)?;
check_cast(id, p, &shapes)
}
/// With the shadow of 3D effect `id` with parameters `p` on, an error when the shadow of `shapes`
/// is past the limits ([`cast_shadow`]). In wireframe mode no shadow is drawn, and none is checked.
fn check_cast(id: &str, p: &Value, shapes: &[Profile]) -> Result<(), String> {
if wireframe::wireframe_params(id, p) {
return Ok(());
}
cast_shadow(id, p, shapes).map(|_| ())
}
/// Do the solids 3D effect `id` with parameters `p` builds from the profiles of `shapes` about `b`
/// build? Only the profiles no solid of [`check_solids`] is built from are built here: every
/// shape's for Rotate, and for Extrude & Bevel and Inflate the profile of a shape with no fill,
/// whose solids are its strokes' outlines or none. A filled shape's profile is its fill's solid,
/// which [`check_solids`] builds once, about the bounds the effect draws it about.
fn check_profiles(id: &str, p: &Value, shapes: &[Profile], b: Rect) -> Result<(), String> {
shapes
.iter()
.filter(|s| is_plane(id) || !s.items.iter().any(|i| matches!(i, AppearanceItem::Fill(_))))
.try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ()))
}
/// Do the solids 3D effect `id` with parameters `p` builds from the fills and strokes of `shapes`
/// fit, each on its own and all of them together ([`built_solids`])? Rotate's planes are not
/// counted.
fn check_solids(id: &str, p: &Value, shapes: &[Profile]) -> Result<(), String> {
let Some(b) = union_bounds(shapes).filter(|_| !is_plane(id)) else { return Ok(()) };
let (parts, bounds) = solid_parts(shapes, b);
built_solids(id, p, &parts, bounds, MAX_OBJECT_FACES).try_for_each(|r| r.map(|_| ()))
}
/// [`profile`], or the error a command reports for object `n`: type with no outlines to build from
/// (empty, or only spaces) is a shape without anchors, an image is traced first, and any other
/// object is the wrong kind.
pub(crate) fn object_profile(n: &Node, label: &str) -> Result<(PathData, FillRule), String> {
profile(n).ok_or_else(|| match n.kind {
NodeKind::Text(_) => format!("{label} requires a shape with at least two anchors"),
NodeKind::Image(_) => source::image_refusal(label),
_ => format!("{label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first."),
})
}
/// A group in a 3D effect's group, or a member of it, whose opacity is below 100%, whose blending
/// mode is not Normal or that has an opacity mask. The members of one group share its `key`.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Scope {
pub(crate) key: usize,
pub(crate) opacity: f32,
pub(crate) blend: BlendMode,
/// The object's opacity mask, unless it has none or it is disabled. The mask art is in
/// document coordinates, as the solids' faces are.
pub(crate) mask: Option<Arc<OpacityMask>>,
}
impl Scope {
/// The scope of `n` with key `key`, or `None` when `n` is opaque, blends normally and has no
/// opacity mask that applies.
fn of(n: &Node, key: usize) -> Option<Self> {
let mask = n.extras().mask.as_deref().filter(|m| !m.disabled);
(n.opacity < 1.0 || n.blend != BlendMode::Normal || mask.is_some()).then(|| Scope {
key,
opacity: n.opacity,
blend: n.blend,
mask: mask.map(|m| Arc::new(m.clone())),
})
}
/// A group of `children` with id `id` and this scope's opacity, blending mode and opacity mask.
pub(crate) fn group(&self, id: NodeId, children: Vec<Arc<Node>>) -> Node {
let mut group = Node::group(id, children);
group.opacity = self.opacity;
group.blend = self.blend;
if let Some(mask) = &self.mask {
group.extras_mut().mask = Some(Box::new((**mask).clone()));
}
group
}
}
/// Does `scope` draw its members' faces other than plainly: does it have an opacity mask or a
/// blending mode other than Normal?
fn composites(scope: &Scope) -> bool {
scope.mask.is_some() || scope.blend != BlendMode::Normal
}
/// `surfaces`, each with the scopes of the member it was built from, in groups with the opacity
/// masks and blending modes of those scopes, so each applies to the faces of the members it holds.
/// The members' opacity is already in their paint ([`Profile::opacity`]), so these groups are
/// opaque.
fn masked(id: NodeId, surfaces: Vec<(&[Scope], Arc<Node>)>) -> Vec<Arc<Node>> {
let masks: Vec<(Vec<Scope>, Arc<Node>)> = surfaces
.into_iter()
.map(|(scopes, s)| {
let masks = scopes.iter().filter(|s| composites(s)).map(|s| Scope { opacity: 1.0, ..s.clone() }).collect();
(masks, s)
})
.collect();
crate::revolve::scoped(id, masks.iter().map(|(scopes, s)| (scopes.as_slice(), s.clone())).collect(), 0)
}
/// Does any of `shapes` sit in a scope with an opacity mask or a blending mode other than Normal?
pub(crate) fn has_masks(shapes: &[Profile]) -> bool {
shapes.iter().any(|s| s.scopes.iter().any(composites))
}
/// One shape a solid is built from: its outline and fill rule, the fills and strokes that paint
/// it, and the opacity of the objects it is in multiplied together (1 for the object with the
/// effect itself). `scopes` lists those objects with transparency or an opacity mask of their own
/// ([`Scope`]), from the outermost group down to the member itself (none for the object itself).
/// Group Revolve applies each once, to the surfaces of the members it holds together; the other 3D
/// effects apply each opacity mask that way.
pub(crate) struct Profile {
pub(crate) path: PathData,
pub(crate) rule: FillRule,
pub(crate) items: Vec<AppearanceItem>,
pub(crate) opacity: f32,
pub(crate) scopes: Vec<Scope>,
}
/// Is `n` a group or layer (a 3D effect on it turns its members about one shared axis)?
pub(crate) fn is_container(n: &Node) -> bool {
matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. })
}
/// The bounds of every shape together: the centre a group's members turn about.
pub(crate) fn union_bounds(shapes: &[Profile]) -> Option<Rect> {
shapes.iter().filter_map(|s| s.path.bounds()).reduce(|a, c| a.union(c))
}
/// The most members a 3D effect on a group builds solids from.
const MAX_MEMBERS: usize = 4096;
/// The most triangles the meshes of one 3D effect hold together (about 290 MB of corners and
/// normals): past it the object is too complex to export.
const MAX_MESH_TRIANGLES: usize = 2_000_000;
/// The most facets of solid colors under colored lights that one 3D effect draws as faces of their
/// own, to go in one order with other solids' faces. Each such face is about 16 nodes, a composite
/// of a whole surface about 6 per facet, so at this count the faces hold about as many nodes as the
/// composites do at the paint-copy allowance. Past it those solids draw as one composite each.
const MAX_FACE_COMPOSITES: usize = 30_720;
/// The shapes a 3D effect on group or layer `n` builds its solids from: every visible path,
/// compound path and type in it (after [`resolve`] gives it the art of its symbol instances, blends
/// and clipping groups), at any depth of its groups, each through its own geometry
/// effects, then those of each group it is in (on that group's members together), and painted
/// with its own fills and strokes. Like the effect on a group in the
/// reference app, they turn about one shared axis (the centre of their bounds together) rather
/// than each about its own. An error names what the group holds that can't become a solid.
pub(crate) fn members(n: &Node, label: &str) -> Result<Vec<Profile>, String> {
struct Walk<'a> {
label: &'a str,
out: Vec<Profile>,
keys: usize,
}
fn walk(n: &Node, opacity: f32, scopes: &[Scope], w: &mut Walk) -> Result<(), String> {
let label = w.label;
for c in n.children().into_iter().flatten() {
if !c.visible || c.is_guide() {
continue;
}
let opacity = opacity * c.opacity;
let mut scopes = scopes.to_vec();
if let Some(scope) = Scope::of(c, w.keys + 1) {
w.keys += 1;
scopes.push(scope);
}
if is_container(c) {
let first = w.out.len();
walk(c, opacity, &scopes, w)?;
// The group's own geometry effects reshape its members together, as it is drawn.
if let Some(shapes) = w.out.get_mut(first..) {
reshape_members(c, &c.appearance.effects, shapes);
}
continue;
}
let Some((path, rule)) = profile(c) else {
let what = match &c.kind {
NodeKind::Image(_) => return Err(source::image_refusal(label)),
NodeKind::Path { clipping: true, .. } => "clipping paths",
_ => "other objects",
};
return Err(format!(
"{label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first."
));
};
if w.out.len() >= MAX_MEMBERS {
return Err(format!("{label} on a group takes at most {MAX_MEMBERS} objects"));
}
let path = geometry(&path, &c.appearance.effects, &GeomContext::of(c));
if path.bounds().is_some() {
w.out.push(Profile { path, rule, items: solid_paints(c), opacity, scopes });
}
}
Ok(())
}
let mut w = Walk { label, out: vec![], keys: 0 };
walk(n, 1.0, &[], &mut w)?;
Ok(w.out)
}
/// Can 3D effect `id` (Revolve included, [`is_three_d`]) with parameters `p` draw `n`? Checked
/// before a command changes the document: Revolve by [`crate::validate_revolve`], the others by
/// [`validate_solid`].
pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> {
validate_three_d_in(None, n, id, p)
}
/// [`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) }
}
/// [`validate_three_d`] for an effect at `(item, index)`: index `index` of the effects of
/// appearance item `item` of `n`, or of the object itself when `item` is `None` (the effects'
/// length for a new effect). An Extrude & Bevel, Inflate or Rotate is checked on the shapes after
/// the geometry effects before that index. A Revolve on a fill or stroke is checked in that item's
/// paint, after the item's geometry effects before it, together with the object's other items that
/// carry a Revolve. A group's or layer's fills and strokes take no 3D effect: only the group's own
/// effect draws.
pub fn validate_three_d_at(n: &Node, id: &str, p: &Value, at: (Option<usize>, usize)) -> Result<(), String> {
validate_three_d_at_in(None, n, id, p, at)
}
/// [`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())
}
(Some(item), index) if crate::revolve::is_revolve(id) => crate::revolve::validate_item_of(doc, n, id, p, item, index),
(None, _) if crate::revolve::is_revolve(id) => crate::revolve::validate_in(doc, n, id, p),
(item, index) => validate_solid_for(doc, n, id, p, (item, Some(index))),
}
}
/// Are 3D effect `id`'s parameter values `p` valid? The checks of [`validate_three_d`] that do not
/// build the art, for an edit that hides the effect: hidden, it draws nothing, so its object does
/// not need to fit.
pub fn validate_three_d_params(id: &str, p: &Value) -> Result<(), String> {
graphics::validate(id, p)?;
if crate::revolve::is_revolve(id) { crate::revolve::validate_params_of(id, p) } else { validate_solid_params(id, p) }
}
/// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and
/// blend mode (so overlapping facets don't add up their transparency). Under colored lights a solid
/// color with smooth facets is one composite of all its facets ([`composites_facets`]) unless
/// `faces`: then each facet is a face of its own, as in a gradient, so the faces can go in one order
/// with the faces of other solids.
fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: BlendMode, colored: bool, faces: bool) -> Node {
if !faces && composites_facets(&facets, paint, colored) {
let mut group = crate::lighting::solid_surface(n.id, &facets, paint);
group.opacity = opacity;
group.blend = blend;
return group;
}
// Preserve existing white-light paints and alpha composition exactly.
let (opaque, mask) = if colored { crate::lighting::opaque_paint(paint) } else { (paint.clone(), None) };
let paint = &opaque;
let children = facets
.into_iter()
.map(|face| {
// Adjacent facets, antialiased on their own, would leave hairline seams: a narrow
// stroke of the same paint closes them ([`seam_width`]).
let seam = seam_width(&face);
if let Some(g) = face.rgb_gradient
&& (paint.color().is_some() || matches!(paint, Paint::Gradient(_)))
{
return Arc::new(crate::lighting::facet_art(n.id, &face.path(), paint, g, seam));
}
let items = crate::lighting::fills(paint, face.illumination, face.brightness, colored);
let items = items
.into_iter()
.flat_map(|item| {
let AppearanceItem::Fill(fill) = &item else { return vec![item] };
let mut edge = crate::lighting::seam_stroke(fill.paint.clone(), seam);
edge.opacity = fill.opacity;
vec![item, AppearanceItem::Stroke(edge)]
})
.collect();
Arc::new(Node::path(n.id, face.path(), Appearance { items, ..Default::default() }))
})
.collect();
let mut group = Node::group(n.id, children);
group.opacity = opacity;
group.blend = blend;
if let Some(mask) = mask {
let bounds = group.visual_bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0);
let coverage = Node::path(n.id, vectorcraft_geom::shapes::rectangle(bounds), Appearance::basic(mask, Paint::None, 0.0));
group.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(coverage, true)));
}
group
}
/// The width of the stroke of a facet's own paint that closes the seams between `face` and its
/// neighbors. No wider than the facet is thick: seen at a grazing angle a facet can be thinner than
/// the stroke, and its stroke would then paint over its neighbors.
pub(crate) fn seam_width(face: &Facet) -> f64 {
(face.thickness() / 2.0).clamp(0.05, 0.75)
}
/// Is a surface of `facets` in `paint` one composite of all its facets when [`surface`] draws it
/// whole: a solid color under colored lights with smooth shading?
fn composites_facets(facets: &[Facet], paint: &Paint, colored: bool) -> bool {
colored && paint.color().is_some() && facets.iter().any(|face| face.rgb_gradient.is_some())
}
/// `item`'s paint, its opacity times `member` (the opacity of the objects it is in) and its blend.
fn paint_of(item: &AppearanceItem, member: f32) -> (Paint, f32, BlendMode) {
match item {
AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity * member, f.blend),
AppearanceItem::Stroke(s) => (s.paint.clone(), s.opacity * member, s.blend),
}
}
/// `item`'s paint, borrowed (for checks that must not copy it).
fn paint_ref(item: &AppearanceItem) -> &Paint {
match item {
AppearanceItem::Fill(f) => &f.paint,
AppearanceItem::Stroke(s) => &s.paint,
}
}
/// Are `facets` drawn over an underlay of themselves ([`crate::lighting::sealed`],
/// [`crate::lighting::solid_surface`])? Under colored lights, when any facet takes a smooth light
/// ramp.
fn underlaid(facets: &[Facet], colored: bool) -> bool {
colored && facets.iter().any(|f| f.rgb_gradient.is_some())
}
/// The most paint copies `surface` makes for one facet ([`facet_copies`]).
const MAX_FACET_COPIES: usize = 12;
/// Paint copies `surface` makes for `facets`: two per facet (fill and seam stroke), twice that when
/// the facets lie over an underlay of themselves, and [`MAX_FACET_COPIES`] when colored lights also
/// give each facet derived paints in three channels (`lighting::solid_surface`).
fn facet_copies(facets: &[Facet], paint: &Paint, colored: bool) -> usize {
let per_facet = match (underlaid(facets, colored), paint.color().is_some()) {
(true, true) => MAX_FACET_COPIES,
(true, false) => 4,
(false, _) => 2,
};
facets.len().saturating_mul(per_facet)
}
/// The area `item` paints on `path`, and the rule that fills it: the path itself for a fill, the
/// stroke's outline (whose contours nest as its holes do) for a stroke.
fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData, FillRule) {
match item {
AppearanceItem::Stroke(s) if s.width > 0.0 && s.width.is_finite() => (crate::stroke::outline_region(path, rule, s), FillRule::EvenOdd),
_ => (path.clone(), rule),
}
}
/// One part of [`solid_parts`]: a fill or stroke, its shape's opacity, the region it paints and its
/// fill rule, its shape's scopes and the index of its shape.
type Part<'a> = (&'a AppearanceItem, f32, PathData, FillRule, &'a [Scope], usize);
/// The parts Extrude & Bevel and Inflate build solids from: each shape's top fill and its strokes,
/// a group's members from the back up, each with its shape's opacity, the region it paints and its
/// shape's scopes. The solids share the bounds of all their parts (`fallback` when none has any):
/// one center to turn, twist and taper about, and Inflate's grid reaches over the strokes' outlines.
fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec<Part<'_>>, Rect) {
let parts: Vec<Part> = shapes
.iter()
.enumerate()
.flat_map(|(member, s)| {
let top_fill = s.items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_)));
s.items.iter().enumerate().filter(move |(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_))).map(move |(_, i)| {
let (area, rule) = region(&s.path, s.rule, i);
(i, s.opacity, area, rule, s.scopes.as_slice(), member)
})
})
.collect();
let bounds = parts.iter().filter_map(|(_, _, area, _, _, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(fallback);
(parts, bounds)
}
#[cfg(test)]
thread_local! {
/// How many solids [`built_solids`] and [`meshes_for`] have built on this thread, for the tests.
/// Rotate's planes drawn, the solids built to cast a shadow ([`cast_shadow`]) and Revolve's
/// surfaces count too.
pub(crate) static SOLIDS_BUILT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
/// How many Extrude & Bevel (Classic included) and Inflate solids [`facets`] and
/// [`built_solids`] have built on this thread, for the tests.
static SOLID_GEOMETRY_BUILT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// The solids Extrude & Bevel (Classic included) or Inflate `id` with parameters `p` builds from
/// `parts` ([`solid_parts`]) about `bounds`, one result for each part in order, each built only as
/// the iterator reaches it. A stroke's solid past its own limits is `None` (left out of the art, its
/// faces not counted); a fill's is an error. The faces of the solids kept count together: once they
/// pass `limit` ([`MAX_OBJECT_FACES`] for the art) the result is an error, and
/// no later part is built. An error ends the iterator.
fn built_solids<'a>(
id: &'a str,
p: &'a Value,
parts: &'a [Part<'a>],
bounds: Rect,
limit: usize,
) -> impl Iterator<Item = Result<Option<Vec<Facet>>, String>> + 'a {
built_solids_cast(id, p, parts, bounds, limit, false).map(|r| r.map(|s| s.map(|(f, _)| f)))
}
/// One solid's facets and, when its shadow is cast, the faces it is cast from.
type CastingSolid = (Vec<Facet>, Option<SolidFaces>);
/// The surfaces of an object's solids, and the faces each solid kept for its shadow
/// ([`surfaces_for`]).
type SurfacesAndFaces = (Vec<Arc<Node>>, Vec<Option<SolidFaces>>);
/// [`built_solids`], and with `cast` for Extrude & Bevel or Inflate each solid's faces its shadow
/// is cast from, from the same build ([`SolidFaces`]).
fn built_solids_cast<'a>(
id: &'a str,
p: &'a Value,
parts: &'a [Part<'a>],
bounds: Rect,
limit: usize,
cast: bool,
) -> impl Iterator<Item = Result<Option<CastingSolid>, String>> + 'a {
// Inflate's eye is placed for every puff together, as far away as the highest needs.
let mut inflated = (id == INFLATE).then(|| -> Box<dyn Iterator<Item = Result<CastingSolid, String>>> {
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect();
let (o, view) = (inflate_options(p), view_options(p));
if cast {
Box::new(vectorcraft_three_d::inflate_shadowed(&shapes, bounds, o, view).map(|r| r.map(|(f, faces)| (f, Some(faces)))))
} else {
Box::new(vectorcraft_three_d::inflate_each(&shapes, bounds, o, view).map(|r| r.map(|f| (f, None))))
}
});
let mut kept = 0usize;
let mut ended = false;
parts.iter().map_while(move |(item, _, area, rule, _, _)| {
if ended {
return None;
}
#[cfg(test)]
SOLIDS_BUILT.with(|n| n.set(n.get() + 1));
let built = match inflated.as_mut() {
Some(each) => {
#[cfg(test)]
SOLID_GEOMETRY_BUILT.with(|n| n.set(n.get() + 1));
each.next().unwrap_or_else(|| Ok((vec![], None)))
}
None => facets_and_faces(id, area, *rule, bounds, p, cast),
};
let result = match built {
Ok(f) => {
kept = kept.saturating_add(f.0.len());
if kept > limit { Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string()) } else { Ok(Some(f)) }
}
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => Ok(None),
Err(e) => Err(e),
};
ended = result.is_err();
Some(result)
})
}
/// What a face of one of the solids [`art_for`] draws in one order was built from: the facet's
/// depth, whether it is a side face and whether it is turned away from the viewer, the bounds of
/// its polygon, the index of its shape, and whether its solid is that shape's fill or one of its
/// strokes that covers the fill's whole edge. A face that [`convex_face`] takes also keeps its
/// polygon and plane, for [`local_order`]; every other face has an empty `outline` and no `plane`.
struct Placed {
depth: f64,
side: bool,
away: bool,
bounds: Rect,
member: usize,
paints: Paints,
outline: Vec<Point>,
plane: Option<DepthPlane>,
}
/// The part of its shape a face's solid is built from ([`Placed`]).
#[derive(Clone, Copy, PartialEq, Eq)]
enum Paints {
Fill,
/// A stroke whose solid holds the fill's whole edge and hides it: one with a width, without
/// dashes, a width profile or a brush, painted with a solid color or a gradient without
/// transparent stops, on a shape whose subpaths are all closed, extruded (an inflated fill can
/// rise in front of its stroke).
Stroke,
Other,
}
/// Can a stroke's solid on a shape with outline `path` hold the fill's edge ([`Paints::Stroke`])
/// under the effect `effect`? Only when the subpaths are all closed and the effect extrudes: an
/// inflated fill can rise in front of its stroke.
fn holds_edge(effect: &str, path: &PathData) -> bool {
effect != INFLATE && path.subpaths.iter().all(|p| p.closed)
}
impl Paints {
/// What `item` of a shape is, its stroke's solid able to hold the fill's edge when `closed`
/// ([`holds_edge`]).
fn of(item: &AppearanceItem, closed: bool) -> Self {
match item {
AppearanceItem::Fill(_) => Paints::Fill,
AppearanceItem::Stroke(s)
if closed
&& s.width > 0.0
&& s.width.is_finite()
&& s.dash.is_none()
&& s.profile.is_none()
&& s.brush.is_none()
&& match &s.paint {
Paint::Solid { .. } => true,
Paint::Gradient(_) => crate::lighting::opaque_paint(&s.paint).1.is_none(),
_ => false,
} =>
{
Paints::Stroke
}
AppearanceItem::Stroke(_) => Paints::Other,
}
}
}
impl Placed {
/// The face of `f`, of shape `member`'s part `paints`.
fn of(f: &Facet, member: usize, paints: Paints) -> Self {
let mut points = f.contours.iter().flatten();
let bounds = points.next().map_or(Rect::ZERO, |p| points.fold(Rect::from_points(*p, *p), |r, q| r.union_pt(*q)));
let local = convex_face(f).is_some();
let outline = f.contours.first().filter(|_| local).cloned().unwrap_or_default();
let plane = f.plane.filter(|_| local);
Placed { depth: f.depth, side: f.side, away: f.away, bounds, member, paints, outline, plane }
}
}
/// The most pairs of faces [`local_order`] compares, and the most [`cut_stroke_faces`] compares;
/// the pairs they have not reached by then keep the order of their depths.
const MAX_LOCAL_CHECKS: usize = 4_000_000;
/// The polygon of `f` and the sign of its turns, when `f` is a face turned toward the viewer other
/// than a planar end, with a plane and one convex contour: a face [`local_order`] and
/// [`cut_stroke_faces`] work on.
fn convex_face(f: &Facet) -> Option<(&[Point], f64)> {
if f.away || f.end.is_some() || f.plane.is_none() || f.contours.len() != 1 {
return None;
}
let c = f.contours.first()?;
Some((c, convex(c)?))
}
/// The bounds of polygon `p` (`Rect::ZERO` when it has no points).
fn polygon_bounds(p: &[Point]) -> Rect {
let mut points = p.iter();
points.next().map_or(Rect::ZERO, |q| points.fold(Rect::from_points(*q, *q), |r, q| r.union_pt(*q)))
}
/// Do rectangles `a` and `b` overlap (more than along an edge)?
fn rects_overlap(a: Rect, b: Rect) -> bool {
a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1
}
/// How many faces [`cut_stroke_faces`] may add to solids painted by `items` that hold `faces` faces
/// and whose paint copies take `paint_bytes` so far: as many as keep the faces within
/// [`MAX_OBJECT_FACES`] and the copies within the allowance, each added face counted as
/// [`MAX_FACET_COPIES`] copies of the costliest stroke's paint.
fn cut_room<'a>(items: impl Iterator<Item = &'a AppearanceItem>, faces: usize, paint_bytes: usize) -> usize {
let per_face = items
.filter(|item| matches!(item, AppearanceItem::Stroke(_)))
.map(|item| {
let mut copies = 0usize;
crate::revolve::paint_copies_add(&mut copies, MAX_FACET_COPIES, paint_ref(item));
copies
})
.max()
.unwrap_or(0)
.max(1);
let affordable = crate::revolve::MAX_PAINT_COPIES.saturating_sub(paint_bytes) / per_face;
MAX_OBJECT_FACES.saturating_sub(faces).min(affordable)
}
/// In `solids`, each solid's part ([`Paints`]), its shape's index and its faces, cut each face of a
/// stroke that does not cover the fill's edge ([`Paints::Other`]) that passes through the plane of a
/// face of the same shape's fill, or of a face of another of the shape's strokes, where the two
/// overlap on the drawing ([`cut_through`]). Of two such strokes, the later one's faces are cut
/// against the earlier one's; a covering stroke's faces ([`Paints::Stroke`]) are never cut, and an
/// other stroke's faces are cut against them. At most `room` pieces are added, the object's solids
/// together.
fn cut_stroke_faces(solids: &mut [(Paints, usize, &mut Vec<Facet>)], mut room: usize) {
let mut checks = 0usize;
for k in 0..solids.len() {
let Some((Paints::Other, shape)) = solids.get(k).map(|(p, s, _)| (*p, *s)) else { continue };
let walls: Vec<usize> = (0..solids.len())
.filter(|&j| {
solids.get(j).is_some_and(|(p, s, _)| {
*s == shape
&& match p {
Paints::Fill | Paints::Stroke => true,
Paints::Other => j < k,
}
})
})
.collect();
for j in walls {
let (wall, stroke) = if j < k {
let (before, from) = solids.split_at_mut(k);
(&**before[j].2, &mut *from[0].2)
} else {
let (before, from) = solids.split_at_mut(j);
(&**from[0].2, &mut *before[k].2)
};
cut_through(wall, stroke, &mut room, &mut checks);
}
}
}
/// Cut each face of `stroke` that passes through the plane of a face of `solid` (the fill's or
/// another stroke's) where their polygons overlap on the drawing, along the line where their depths
/// meet, so that each piece is wholly in front of or behind that face there ([`local_order`] can
/// then order them). A dash's end, which crosses the edge of the fill it is drawn on, is cut into its
/// part outside the fill, in front of the fill's wall, and its part inside, behind it. A piece keeps
/// the part of the face's curved outline on its side of the cut. The first piece of a face takes its
/// place, the others go at the end. Each cut adds a face out of `room`, and each pair compared counts
/// in `checks` ([`MAX_LOCAL_CHECKS`]); cutting stops when either runs out.
fn cut_through(solid: &[Facet], stroke: &mut Vec<Facet>, room: &mut usize, checks: &mut usize) {
// The faces from left to right: a face is compared with those that start left of its end.
let mut walls: Vec<(&Facet, &[Point], f64, Rect)> =
solid.iter().filter_map(|f| convex_face(f).map(|(c, turn)| (f, c, turn, polygon_bounds(c)))).collect();
if walls.is_empty() {
return;
}
walls.sort_by(|a, b| a.3.x0.total_cmp(&b.3.x0));
let mut k = 0;
while k < stroke.len() {
let mut extent = stroke.get(k).and_then(convex_face).map(|(c, _)| polygon_bounds(c));
for &(wall, outline, turn, bounds) in &walls {
let Some(at) = extent.filter(|e| bounds.x0 < e.x1) else { break };
if *checks >= MAX_LOCAL_CHECKS || *room == 0 {
return;
}
*checks += 1;
if !rects_overlap(at, bounds) {
continue;
}
let Some(s) = stroke.get(k) else { break };
let Some((c, _)) = convex_face(s) else { break };
if let Some((near, far)) = cut(s, c, wall, outline, turn) {
*room -= 1;
extent = convex_face(&near).map(|(c, _)| polygon_bounds(c));
stroke[k] = near;
stroke.push(far);
}
}
k += 1;
}
}
/// Face `s` (polygon `c`) cut where its depth meets that of face `wall` (convex polygon `outline`,
/// turning with sign `turn`): its piece nearer the viewer than the wall's plane and its piece
/// farther. `None` when the two do not overlap on the drawing or `s` is on one side of the wall's
/// plane wherever they do.
fn cut(s: &Facet, c: &[Point], wall: &Facet, outline: &[Point], turn: f64) -> Option<(Facet, Facet)> {
let ahead = |p: Point| Some(s.depth_at(p)? - wall.depth_at(p)?);
let overlap = clip_convex(c, outline, turn);
centroid(&overlap)?;
let tolerance = 1e-6;
let (mut nearer, mut farther) = (false, false);
for p in &overlap {
let d = ahead(*p)?;
nearer |= d > tolerance;
farther |= d < -tolerance;
}
if !(nearer && farther) {
return None;
}
let (a, b) = (s.plane?, wall.plane?);
let values: Vec<f64> = match (a.eye, b.eye) {
(Some(e), Some(f)) if e == f && a.center == b.center => {
// With perspective the depths themselves are not linear on the drawing, but where they
// meet is a line: (dA - aA e)(nzB - aB) = (dB - aB e)(nzA - aA), with aX = (nX · p) / e,
// whose difference is linear. Its sign is that of the depths' difference over the
// product of the two denominators, taken at the middle of the face.
let at = |p: Point| {
let (x, y) = (p.x - a.center.x, p.y - a.center.y);
let (aa, ab) = ((a.normal[0] * x + a.normal[1] * y) / e, (b.normal[0] * x + b.normal[1] * y) / e);
let (da, db) = (a.normal[2] - aa, b.normal[2] - ab);
((a.offset - aa * e) * db - (b.offset - ab * e) * da, da * db)
};
let sign = at(centroid(c)?).1.signum();
c.iter().map(|p| at(*p).0 * sign).collect()
}
_ => c.iter().map(|p| ahead(*p)).collect::<Option<_>>()?,
};
let piece = |sign: f64| {
let points = half(c, &values, sign);
let mid = centroid(&points)?;
let mut f = s.clone();
f.depth = s.depth_at(mid).unwrap_or(s.depth);
f.contours = vec![points];
f.outline = s.outline.as_ref().and_then(|o| clip_to_side(o, c, &values, sign));
Some(f)
};
Some((piece(1.0)?, piece(-1.0)?))
}
/// Curved outline `outline` of a face with polygon `c`, cut along the line where `values` (one per
/// point of `c`, varying linearly along each edge) is 0, keeping the side where `sign` times them is
/// at least 0. `None` when the line does not cross `c` at two points or nothing is left.
fn clip_to_side(outline: &PathData, c: &[Point], values: &[f64], sign: f64) -> Option<PathData> {
let mut crossings = vec![];
for (j, (&a, &va)) in c.iter().zip(values).enumerate() {
let k = (j + 1) % c.len();
let (Some(&b), Some(&vb)) = (c.get(k), values.get(k)) else { continue };
if (va >= 0.0) != (vb >= 0.0) {
crossings.push(a.lerp(b, va / (va - vb)));
}
}
let &[q0, q1] = crossings.as_slice() else { return None };
let along = q1 - q0;
let length = along.hypot();
if length.is_nan() || length <= 1e-9 {
return None;
}
let along = along / length;
let (inside, _) = c.iter().zip(values).find(|(_, v)| **v * sign > 0.0)?;
let mut across = Vec2::new(-along.y, along.x);
if (*inside - q0).dot(across) < 0.0 {
across = -across;
}
let bounds = outline.bounds()?.union(polygon_bounds(c));
let reach = 2.0 * (bounds.width() + bounds.height() + (q0 - bounds.center()).hypot()) + 1.0;
let mut side = BezPath::new();
side.move_to(q0 - along * reach);
side.line_to(q0 + along * reach);
side.line_to(q0 + along * reach + across * reach);
side.line_to(q0 - along * reach + across * reach);
side.close_path();
let kept = vectorcraft_pathops::boolean(outline, FillRule::NonZero, &PathData::from_bezpath(&side), FillRule::NonZero, BoolOp::Intersect);
kept.bounds().is_some().then_some(kept)
}
/// The part of polygon `p` where `sign` times `values` (one per point, varying linearly along each
/// edge) is at least 0.
fn half(p: &[Point], values: &[f64], sign: f64) -> Vec<Point> {
let mut out = vec![];
for (j, (&a, &va)) in p.iter().zip(values).enumerate() {
let k = (j + 1) % p.len();
let (Some(&b), Some(&vb)) = (p.get(k), values.get(k)) else { continue };
let (sa, sb) = (va * sign, vb * sign);
if sa >= 0.0 {
out.push(a);
}
if (sa >= 0.0) != (sb >= 0.0) {
out.push(a.lerp(b, sa / (sa - sb)));
}
}
out
}
/// `order` (back to front), when it draws a face of one of a shape's strokes and an overlapping face
/// of the same shape's fill or strokes the wrong way round for their depths where they overlap on
/// the drawing, with every pair of overlapping faces that keep a polygon and a plane
/// ([`convex_face`]: turned toward the viewer, not a planar end), of any shape, put in the order of
/// their depths where they overlap: the face nearer the viewer there goes after the other. A fill
/// wall and a face of the same shape's covering stroke ([`Paints::Stroke`]) keep their order. Each
/// face otherwise keeps its place in `order`, so a fill wall in the gap between two dashes stays
/// where its depth puts it. A cycle of such pairs is broken at the face that comes first in `order`.
/// A face drawn last (a planar end turned toward the viewer, at depth `f64::MAX`) keeps its place:
/// no face moves across it. Otherwise `order` as it is.
fn local_order(faces: &[Placed], order: Vec<usize>) -> Vec<usize> {
use std::cmp::Reverse;
// The faces of each shape that take part, from left to right for a sweep along x.
let mut shapes: Vec<Vec<usize>> = vec![];
for (k, f) in faces.iter().enumerate() {
if f.outline.len() < 3 || f.plane.is_none() {
continue;
}
if shapes.len() <= f.member {
shapes.resize_with(f.member + 1, Vec::new);
}
shapes[f.member].push(k);
}
if !shapes.iter().any(|list| list.iter().any(|&k| faces[k].paints != Paints::Fill)) {
return order;
}
for list in &mut shapes {
list.sort_by(|&a, &b| faces[a].bounds.x0.total_cmp(&faces[b].bounds.x0));
}
// A fill wall and a face of the same shape's covering stroke keep the order [`draw_order`] gave
// them: the stroke's solid holds the wall, which never shows.
let covered = |f: &Placed, s: &Placed| f.paints == Paints::Fill && f.side && s.paints == Paints::Stroke && f.member == s.member;
// Is there a face of a shape's strokes and a face of the same shape's fill or strokes that the
// order of their depths draws the wrong way round where they overlap?
let mut position = vec![0usize; faces.len()];
for (at, &k) in order.iter().enumerate() {
position[k] = at;
}
let mut checks = 0usize;
let mut misordered = false;
'shapes: for list in &shapes {
for (i, &f) in list.iter().enumerate() {
let a = &faces[f];
for &s in list.iter().skip(i + 1).take_while(|&&s| faces[s].bounds.x0 < a.bounds.x1) {
if checks >= MAX_LOCAL_CHECKS {
break 'shapes;
}
checks += 1;
let b = &faces[s];
if (a.paints == Paints::Fill && b.paints == Paints::Fill) || covered(a, b) || covered(b, a) || !rects_overlap(a.bounds, b.bounds) {
continue;
}
if nearer_where_overlapping(a, b).is_some_and(|a_nearer| a_nearer != (position[f] > position[s])) {
misordered = true;
break 'shapes;
}
}
}
}
if !misordered {
return order;
}
// Moving those faces must not carry them past any other face they overlap, so every pair of
// faces with a polygon and a plane that overlap goes in the order of their depths there.
let mut sweep: Vec<usize> = (0..faces.len()).filter(|&k| faces[k].outline.len() >= 3 && faces[k].plane.is_some()).collect();
sweep.sort_by(|&a, &b| faces[a].bounds.x0.total_cmp(&faces[b].bounds.x0));
// For each face, the faces drawn after it, and for each, how many go before it.
let mut after: Vec<Vec<usize>> = vec![vec![]; faces.len()];
let mut waits = vec![0usize; faces.len()];
'pairs: for (i, &k) in sweep.iter().enumerate() {
let a = &faces[k];
for &j in sweep.iter().skip(i + 1).take_while(|&&j| faces[j].bounds.x0 < a.bounds.x1) {
if checks >= MAX_LOCAL_CHECKS {
break 'pairs;
}
checks += 1;
let b = &faces[j];
if !rects_overlap(a.bounds, b.bounds) {
continue;
}
let a_later = if covered(a, b) || covered(b, a) {
position[k] > position[j]
} else {
let Some(a_nearer) = nearer_where_overlapping(a, b) else { continue };
a_nearer
};
let (back, front) = if a_later { (j, k) } else { (k, j) };
after[back].push(front);
waits[front] += 1;
}
}
// Each step draws the face that comes first in `order` of those whose faces before are drawn.
let mut ready: std::collections::BinaryHeap<Reverse<(usize, usize)>> =
(0..faces.len()).filter(|&k| waits[k] == 0).map(|k| Reverse((position[k], k))).collect();
// The faces drawn last, in `order`: each goes only after every face before it in `order`.
let last: Vec<usize> = order.iter().copied().filter(|&k| faces[k].depth >= f64::MAX).collect();
let mut next_last = 0usize;
let mut drawn = vec![false; faces.len()];
let mut out = Vec::with_capacity(order.len());
let mut first = 0usize;
while out.len() < order.len() {
while last.get(next_last).is_some_and(|&k| drawn[k]) {
next_last += 1;
}
let limit = last.get(next_last).map_or(usize::MAX, |&k| position[k]);
let k = match ready.peek() {
Some(&Reverse((at, k))) if at < limit => {
ready.pop();
k
}
_ => {
// A cycle, or the faces left to draw before a face drawn last wait for faces after
// it: the first face in `order` not drawn yet goes next.
while order.get(first).is_some_and(|&k| drawn[k]) {
first += 1;
}
let Some(&k) = order.get(first) else { break };
k
}
};
if drawn[k] {
continue;
}
drawn[k] = true;
out.push(k);
for &j in &after[k] {
waits[j] = waits[j].saturating_sub(1);
if waits[j] == 0 && !drawn[j] {
ready.push(Reverse((position[j], j)));
}
}
}
out
}
/// Whether face `a` is nearer the viewer than face `b` where their polygons overlap on the drawing,
/// measured at the middle of the overlap. `None` when they do not overlap, when either polygon is
/// not convex, or when they are at the same depth there.
fn nearer_where_overlapping(a: &Placed, b: &Placed) -> Option<bool> {
let (pa, pb) = (a.plane?, b.plane?);
let turn = convex(&b.outline)?;
convex(&a.outline)?;
let overlap = clip_convex(&a.outline, &b.outline, turn);
let mid = centroid(&overlap)?;
let (da, db) = (pa.depth_at(mid)?, pb.depth_at(mid)?);
let tolerance = 1e-6 * (1.0 + da.abs().max(db.abs()));
((da - db).abs() > tolerance).then_some(da > db)
}
/// The sign of the turns of convex polygon `p` (`None` when it is not convex or has no area).
fn convex(p: &[Point]) -> Option<f64> {
let n = p.len();
let mut sign = 0.0;
for i in 0..n {
let (a, b, c) = (p[i], p[(i + 1) % n], p[(i + 2) % n]);
let cross = (b - a).cross(c - b);
if cross.abs() <= 1e-12 {
continue;
}
if sign == 0.0 {
sign = cross.signum();
} else if cross.signum() != sign {
return None;
}
}
(sign != 0.0).then_some(sign)
}
/// The part of polygon `subject` inside convex polygon `clip`, whose turns have sign `turn`
/// (Sutherland–Hodgman).
fn clip_convex(subject: &[Point], clip: &[Point], turn: f64) -> Vec<Point> {
let mut out = subject.to_vec();
for i in 0..clip.len() {
if out.is_empty() {
break;
}
let (e0, e1) = (clip[i], clip[(i + 1) % clip.len()]);
let side = |p: Point| (e1 - e0).cross(p - e0) * turn;
let input = std::mem::take(&mut out);
for j in 0..input.len() {
let (p, q) = (input[j], input[(j + 1) % input.len()]);
let (sp, sq) = (side(p), side(q));
if sp >= 0.0 {
out.push(p);
}
if (sp >= 0.0) != (sq >= 0.0) {
let t = sp / (sp - sq);
out.push(p.lerp(q, t));
}
}
}
out
}
/// The centroid of polygon `p`, `None` when it has (almost) no area. It is measured from the
/// polygon's first point, so a small polygon far from the origin keeps its area.
fn centroid(p: &[Point]) -> Option<Point> {
let origin = *p.first()?;
let n = p.len();
let (mut area, mut cx, mut cy) = (0.0, 0.0, 0.0);
for i in 0..n {
let (a, b) = (p[i] - origin, p[(i + 1) % n] - origin);
let cross = a.x * b.y - b.x * a.y;
area += cross;
cx += (a.x + b.x) * cross;
cy += (a.y + b.y) * cross;
}
(area.abs() > 1e-9).then(|| origin + Vec2::new(cx / (3.0 * area), cy / (3.0 * area)))
}
/// The most bounds comparisons [`draw_order`] makes; the fill walls it has not reached by then keep
/// their depths.
const MAX_OVERLAP_CHECKS: usize = 4_000_000;
/// The order faces `faces` are drawn in, back to front, as indices into `faces`: by depth (faces
/// at the same depth in the order given), except that with `sealed` (solids with caps) a side face
/// of a shape's fill turned toward the viewer is drawn before each face of the same shape's
/// covering strokes ([`Paints::Stroke`]), turned toward the viewer, whose bounds overlap its own.
/// Such a stroke's closed solid holds the fill's walls, so they never show and can be drawn under
/// any other face. A shape's fill faces and the faces of its other strokes then go in the order of
/// their depths where they overlap ([`local_order`]).
fn draw_order(faces: &[Placed], sealed: bool) -> Vec<usize> {
// Each face's depth to sort by, and whether it was moved before a stroke face at that depth.
let mut keys: Vec<(f64, bool)> = faces.iter().map(|f| (f.depth, false)).collect();
if sealed {
// The stroke faces turned toward the viewer of each shape, nearest last.
let mut strokes: Vec<Vec<&Placed>> = vec![];
for f in faces.iter().filter(|f| f.paints == Paints::Stroke && !f.away) {
if strokes.len() <= f.member {
strokes.resize_with(f.member + 1, Vec::new);
}
strokes[f.member].push(f);
}
for list in &mut strokes {
list.sort_by(|a, b| a.depth.total_cmp(&b.depth));
}
let overlap = |a: &Rect, b: &Rect| a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
let mut checks = 0usize;
for (f, key) in faces.iter().zip(&mut keys) {
if f.paints != Paints::Fill || !f.side || f.away {
continue;
}
let Some(list) = strokes.get(f.member).filter(|l| !l.is_empty()) else { continue };
for s in list.iter().take_while(|s| s.depth <= f.depth) {
if checks >= MAX_OVERLAP_CHECKS {
break;
}
checks += 1;
if overlap(&s.bounds, &f.bounds) {
*key = (s.depth, true);
break;
}
}
}
}
let mut order: Vec<usize> = (0..faces.len()).collect();
// A stable sort: faces at the same depth keep the order of the appearance, and a fill's side
// face moved to a stroke face's depth goes before it.
order.sort_by(|&a, &b| keys[a].0.total_cmp(&keys[b].0).then(keys[b].1.cmp(&keys[a].1)));
local_order(faces, order)
}
/// The order, back to front, of the faces of one or more Revolve surfaces `faces`, given back to
/// front by depth: each face's polygon on the drawing, its plane and its depth. When two
/// overlapping faces are drawn the wrong way round for their depths where they overlap, every pair
/// of overlapping faces with a convex polygon and a plane goes in the order of its depths there,
/// the face nearer the viewer after the other ([`local_order`]), and each step draws the face that
/// comes first in the given order of those whose faces before are drawn, so a face without a plane
/// or a convex polygon can also move. Otherwise the order given.
pub(crate) fn surface_order<'a>(faces: impl IntoIterator<Item = (&'a [Point], Option<DepthPlane>, f64)>) -> Vec<usize> {
let placed: Vec<Placed> = faces
.into_iter()
.map(|(points, plane, depth)| {
let local = plane.is_some() && convex(points).is_some();
// Every face counts as one of a stroke's on one shape, so each overlapping pair is compared.
Placed {
depth,
side: true,
away: false,
bounds: polygon_bounds(points),
member: 0,
paints: Paints::Other,
outline: if local { points.to_vec() } else { vec![] },
plane: plane.filter(|_| local),
}
})
.collect();
let order = (0..placed.len()).collect();
local_order(&placed, order)
}
/// `shapes`, the members of group or layer `n`, reshaped by the visible geometry effects `before`
/// its 3D effect, about the bounds of all of them.
pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) {
if before.iter().any(|e| e.visible && is_geometry(&e.id))
&& let Some(b) = union_bounds(shapes)
{
let ctx = GeomContext::of(n);
for s in shapes {
s.path = apply_geometry_with(before, &s.path, b, &ctx);
}
}
}
/// A 3D effect's shadow and its surfaces ([`art_for`]).
type ShadowAndSurfaces = (Vec<Arc<Node>>, Vec<Arc<Node>>);
/// The 3D art of one effect `fx` on `path` (already through the effects before it).
/// Rotate turns every fill and stroke, each in its own paint. Extrude & Bevel and Inflate build a
/// solid from the path for the top visible fill and one from the outline of each visible stroke,
/// each in its own paint. When every one is opaque with normal blending, their faces are drawn in
/// one back-to-front order, a solid color under colored lights included up to
/// [`MAX_FACE_COMPOSITES`] facets (where depths tie, as
/// for the caps turned toward the viewer, in the order of the appearance, and with caps, a shape's
/// fill walls before the faces of its covering strokes that overlap them, and its fill's faces and
/// its other strokes' faces, cut where they pass through each other ([`cut_stroke_faces`]), in the
/// order of their depths where they overlap: [`draw_order`]), the
/// faces of a solid in a gradient with transparent stops in runs that carry its opacity mask
/// ([`in_runs`]);
/// otherwise each solid is a surface of its own, in that order. A member's opacity mask and
/// 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), cast from the faces the solids drawn kept ([`cast_built`]), to be drawn behind every
/// surface of the object, and the surfaces.
fn art_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<ShadowAndSurfaces> {
// In wireframe mode the effect draws the edges of the faces it builds, with no shadow.
if wireframe::renders_wireframe(fx) {
return Some((vec![], wireframe::wire_surfaces(n.id, fx, shapes)?));
}
let p = crate::merged_params(&fx.id, &fx.params);
let cast = !is_classic_three_d(&fx.id) && shadow::options(&p).is_some();
let (art, built) = surfaces_for(doc, n, fx, shapes, cast)?;
// A shadow past the limits draws the source path, as a solid past them does; commands report
// the error ([`validate_solid_in`]).
let shadow = if cast { cast_built(&fx.id, &p, shapes, built) } else { Ok(None) };
let shadow = match shadow.ok()? {
Some((caster, settings, cast)) => shadow::scoped_art(n.id, &caster, settings, cast, has_masks(shapes)),
None => vec![],
};
Some((shadow, art))
}
/// The shadow [`art_for`] draws for effect `fx` on `shapes` behind the surfaces of object `id`:
/// none with the shadow off or in wireframe mode, and an error when it is past the limits.
pub(crate) fn shadow_for(id: NodeId, fx: &Effect, shapes: &[Profile]) -> Result<Vec<Arc<Node>>, String> {
if wireframe::renders_wireframe(fx) {
return Ok(vec![]);
}
Ok(match cast_shadow(&fx.id, &crate::merged_params(&fx.id, &fx.params), shapes)? {
Some((caster, settings, cast)) => shadow::scoped_art(id, &caster, settings, cast, has_masks(shapes)),
None => vec![],
})
}
/// What the shadow of the solids [`surfaces_for`] builds for effect `id` with parameters `p` on
/// `shapes` is cast from, on one plane: the faces of every solid, the shadow's settings and the
/// faces of each solid of visible art with its member's scopes and ink. `None` with the shadow
/// off, for Extrude & Bevel (Classic) and Rotate (Classic) or with no shapes. A stroke's solid that is not built (past
/// the limits) casts nothing, as it draws nothing; any other solid that is not built is an error.
/// When a member's opacity mask or blending mode applies to its solids ([`masked`]), it applies to
/// the part of the shadow they cast too ([`shadow::scoped_art`]).
fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result<Option<Cast<'a>>, String> {
if id == CLASSIC_EXTRUDE || id == CLASSIC_ROTATE {
return Ok(None);
}
let (Some(settings), Some(b)) = (shadow::options(p), union_bounds(shapes)) else { return Ok(None) };
let view = view_options(p);
let mut caster = vectorcraft_three_d::ShadowCaster::new();
// The faces each solid of visible art added, with its member's scopes and its ink (opacity
// and blending mode), in order. A fully transparent fill or stroke (or member) casts nothing
// and does not place the plane.
let mut cast: Vec<shadow::Cast> = vec![];
let ink = |item: &AppearanceItem, member: f32| {
let (_, opacity, blend) = paint_of(item, member);
shadow::Ink { opacity, blend }
};
if id == ROTATE {
for s in shapes {
for item in s.items.iter().filter(|i| ink(i, s.opacity).opacity > 0.0) {
let (area, rule) = region(&s.path, s.rule, item);
#[cfg(test)]
SOLIDS_BUILT.with(|n| n.set(n.get() + 1));
let faces = caster.plane(&area, rule, b, view)?;
cast.push((s.scopes.as_slice(), ink(item, s.opacity), faces));
}
}
} else {
let (parts, bounds) = solid_parts(shapes, b);
let inks: Vec<shadow::Ink> = parts.iter().map(|(item, member, ..)| ink(item, *member)).collect();
// Inflate's eye is placed for every shape together, as the solids are.
let added: Vec<Result<std::ops::Range<usize>, String>> = if id == INFLATE {
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect();
let casts: Vec<bool> = inks.iter().map(|i| i.opacity > 0.0).collect();
#[cfg(test)]
SOLIDS_BUILT.with(|n| n.set(n.get() + shapes.len()));
caster.inflate(&shapes, &casts, bounds, inflate_options(p), view)
} else {
let mut added = vec![];
for ((_, _, area, rule, _, _), ink) in parts.iter().zip(&inks) {
#[cfg(test)]
SOLIDS_BUILT.with(|n| n.set(n.get() + usize::from(ink.opacity > 0.0)));
added.push(if ink.opacity > 0.0 { caster.extrude(area, *rule, bounds, extrude_options(p), view) } else { Ok(0..0) });
}
added
};
for (((item, _, _, _, scopes, _), ink), faces) in parts.iter().zip(inks).zip(added) {
if ink.opacity <= 0.0 {
continue;
}
// A stroke whose own solid is past the limits is left out; past the shadow's limit for
// all the solids together, the shadow is refused.
let faces = match faces {
Ok(faces) => faces,
Err(e) if matches!(item, AppearanceItem::Stroke(_)) && e != vectorcraft_three_d::TOO_COMPLEX_SHADOW => continue,
Err(e) => return Err(e),
};
cast.push((*scopes, ink, faces));
}
}
Ok(Some((caster, settings, cast)))
}
/// [`cast_shadow`] from `built`, the faces the solids drawn kept ([`surfaces_for`] with its shadow
/// cast), one entry for each solid in the order they are built: for Rotate each fill and stroke of
/// each shape, otherwise each part ([`solid_parts`]). A stroke's solid left out of the art (past its
/// own limits) kept no faces. Its Extrude & Bevel solid is built for the shadow alone, as
/// [`cast_shadow`] builds it, and when an Inflate solid of visible art kept no faces the whole
/// shadow is cast as [`cast_shadow`] casts it, since Inflate's solids share one eye.
fn cast_built<'a>(id: &str, p: &Value, shapes: &'a [Profile], built: Vec<Option<SolidFaces>>) -> Result<Option<Cast<'a>>, String> {
if id == CLASSIC_EXTRUDE || id == CLASSIC_ROTATE {
return Ok(None);
}
let (Some(settings), Some(b)) = (shadow::options(p), union_bounds(shapes)) else { return Ok(None) };
let view = view_options(p);
let mut caster = vectorcraft_three_d::ShadowCaster::new();
let mut cast: Vec<shadow::Cast> = vec![];
let ink = |item: &AppearanceItem, member: f32| {
let (_, opacity, blend) = paint_of(item, member);
shadow::Ink { opacity, blend }
};
if id == ROTATE {
let items: Vec<(&Profile, &AppearanceItem)> = shapes.iter().flat_map(|s| s.items.iter().map(move |item| (s, item))).collect();
if items.len() != built.len() {
return cast_shadow(id, p, shapes);
}
for ((s, item), kept) in items.into_iter().zip(built) {
let ink = ink(item, s.opacity);
if ink.opacity <= 0.0 {
continue;
}
let faces = match kept {
Some(kept) => caster.add_solid(kept)?,
None => {
let (area, rule) = region(&s.path, s.rule, item);
#[cfg(test)]
SOLIDS_BUILT.with(|n| n.set(n.get() + 1));
caster.plane(&area, rule, b, view)?
}
};
cast.push((s.scopes.as_slice(), ink, faces));
}
return Ok(Some((caster, settings, cast)));
}
let (parts, bounds) = solid_parts(shapes, b);
let inks: Vec<shadow::Ink> = parts.iter().map(|(item, member, ..)| ink(item, *member)).collect();
if parts.len() != built.len() || (id == INFLATE && inks.iter().zip(&built).any(|(ink, kept)| ink.opacity > 0.0 && kept.is_none())) {
return cast_shadow(id, p, shapes);
}
for (((item, _, area, rule, scopes, _), ink), kept) in parts.iter().zip(inks).zip(built) {
if ink.opacity <= 0.0 {
continue;
}
let faces = match kept {
Some(kept) => caster.add_solid(kept),
None => {
#[cfg(test)]
SOLIDS_BUILT.with(|n| n.set(n.get() + 1));
caster.extrude(area, *rule, bounds, extrude_options(p), view)
}
};
// A stroke whose own solid is past the limits is left out; past the shadow's limit for all
// the solids together, the shadow is refused.
let faces = match faces {
Ok(faces) => faces,
Err(e) if matches!(item, AppearanceItem::Stroke(_)) && e != vectorcraft_three_d::TOO_COMPLEX_SHADOW => continue,
Err(e) => return Err(e),
};
cast.push((*scopes, ink, faces));
}
Ok(Some((caster, settings, cast)))
}
/// The faces a shadow is cast from, its settings, and the faces of each solid of visible art with
/// its member's scopes and ink ([`cast_shadow`]).
type Cast<'a> = (vectorcraft_three_d::ShadowCaster, vectorcraft_three_d::Shadow, Vec<shadow::Cast<'a>>);
/// 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()
}
/// A graphic of [`placed_graphics`]: the index of the first surface of the solids that share it,
/// the index of the surface it is drawn right after, its face and its art.
type Placement = (usize, usize, PlanarFace, Arc<Node>);
/// 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. Solids in a run
/// with the same opacity and blending mode share their graphics, one on each 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<Placement> {
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. One member's solids share their graphics
// whether or not their surfaces carry an opacity mask, and each graphic takes the mask of
// the surface it is drawn after ([`with_graphics`]). Solids of different members share
// them only when neither surface carries a mask. A solid joins only the group of the solid
// before it, so a graphic is never drawn past a solid painted between.
let joins = |g: &&Filled| {
let member = g.member == f.member;
(member || !per_member && !g.masked && !f.masked) && g.opacity == f.opacity && g.blend == f.blend
};
match groups.last_mut().filter(|g| g.iter().all(joins)) {
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 first = group.first().map_or(0, |f| f.at);
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((first, 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: &[Placement]) -> Vec<(&'s [Scope], Arc<Node>)> {
with_graphics(surfaces.into_iter().map(|(_, s, scopes, _)| (scopes, Arc::new(s))).collect(), 0, arts)
}
/// `surfaces` (those from index `first`) drawn whole, each with its scopes, and the graphics `arts`
/// ([`placed_graphics`]) of those surfaces 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>)>, first: usize, arts: &[Placement]) -> 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 == first + 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. With `cast`, each
/// solid built also keeps the faces its shadow is cast from, from the same build, in the order the
/// solids are built ([`cast_built`]); `None` for a stroke's solid left out of the art.
fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile], cast: bool) -> Option<SurfacesAndFaces> {
let b = union_bounds(shapes)?;
let p = crate::merged_params(&fx.id, &fx.params);
let classic = fx.id == CLASSIC_EXTRUDE || fx.id == CLASSIC_ROTATE;
let colored = !classic && view_options(&p).has_colored_lights();
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![];
let mut filled = vec![];
let mut built = 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);
#[cfg(test)]
SOLIDS_BUILT.with(|n| n.set(n.get() + 1));
let (f, faces) = facets_and_faces(ROTATE, &area, rule, b, &p, cast).ok()?;
built.push(faces);
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, false) };
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, 0, &arts);
return Some((if masks { masked(n.id, out) } else { out.into_iter().map(|(_, s)| s).collect() }, built));
}
let (parts, bounds) = solid_parts(shapes, b);
let mut solids = vec![];
let mut built = vec![];
for ((item, opacity, _, _, scopes, member), f) in parts.iter().zip(built_solids_cast(&fx.id, &p, &parts, bounds, MAX_OBJECT_FACES, cast)) {
let item: &AppearanceItem = item;
match f {
Ok(Some((f, _))) if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, paint_ref(item), colored), paint_ref(item)) => {
return None;
}
Ok(Some((f, faces))) => {
solids.push((item, *opacity, *scopes, f, *member));
built.push(faces);
}
// A stroke's outline past the limits is left out; the fill's solid still draws.
Ok(None) => built.push(None),
// A loaded file or a shape edited past the limits (its own, or those of the object's
// solids together) keeps drawing its source path instead of failing the frame;
// commands validate the same evaluator and report its errors.
Err(_) => return None,
}
}
if solids.is_empty() {
return None;
}
let opaque = solids.iter().all(|(item, member, _, _, _)| {
let (_, opacity, blend) = paint_of(item, *member);
opacity >= 1.0 && blend == BlendMode::Normal
});
let closed = |shape: usize| shapes.get(shape).is_some_and(|s| holds_edge(&fx.id, &s.path));
// A solid color under colored lights with smooth shading draws as one composite of its facets
// ([`surface`]) unless its facets go in one order with other solids' faces. They do while all
// such facets together number at most MAX_FACE_COMPOSITES.
let composite = |item: &AppearanceItem, f: &[Facet]| composites_facets(f, paint_ref(item), colored);
let colored_faces: usize = solids.iter().filter(|(item, _, _, f, _)| composite(item, f)).map(|(.., f, _)| f.len()).sum();
let split = colored_faces <= MAX_FACE_COMPOSITES;
// The faces are drawn in one order (below) when every solid is opaque and blends normally and
// no composite draws a surface whole. Under a member's opacity mask or blending, each member's
// faces go in one order of their own when its fills and strokes are opaque, blend normally,
// carry no opacity mask and are not composites.
let one_order = opaque && (split || !solids.iter().any(|(item, _, _, f, _)| composite(item, f)));
let shape_order: Vec<bool> = (0..shapes.len())
.map(|shape| {
solids.iter().filter(|s| s.4 == shape).all(|(item, member, _, f, _)| {
let (paint, opacity, blend) = paint_of(item, *member);
opacity >= 1.0 && blend == BlendMode::Normal && !masks_surface(&paint, colored) && (split || !composite(item, f))
})
})
.collect();
let ordered = |shape: usize| if masks { shape_order.get(shape).copied().unwrap_or(false) } else { one_order };
// The solids whose faces share one order with another solid's: every solid in the one order of
// the object, or of its member under a mask. A lone solid draws its facets in their own order,
// so a solid color under colored lights stays one composite there ([`surface`]).
let shared: Vec<bool> =
(0..shapes.len()).map(|shape| ordered(shape) && solids.iter().filter(|s| !masks || s.4 == shape).nth(1).is_some()).collect();
if solids.iter().any(|s| ordered(s.4)) {
// A stroke face that passes through a fill wall or another stroke's face is cut where it
// does. The pieces count against the limits as the faces did: cutting stops before the
// faces pass MAX_OBJECT_FACES or their paint copies pass the allowance, so an effect that
// draws uncut still draws.
let counts: Vec<usize> = solids.iter().map(|(.., f, _)| f.len()).collect();
let room = cut_room(solids.iter().map(|(item, ..)| *item), counts.iter().sum(), paint_bytes);
let mut cuts: Vec<(Paints, usize, &mut Vec<Facet>)> = solids
.iter_mut()
.filter(|(.., shape)| ordered(*shape))
.map(|(item, _, _, f, shape)| (Paints::of(item, closed(*shape)), *shape, f))
.collect();
cut_stroke_faces(&mut cuts, room);
for ((item, _, _, f, _), before) in solids.iter().zip(counts) {
let added = f.get(before..).unwrap_or_default();
if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(added, paint_ref(item), colored), paint_ref(item)) {
return None;
}
}
}
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)
};
// With the pieces of the cuts, solid colors under colored lights past MAX_FACE_COMPOSITES draw
// as one composite each, so their surfaces draw whole (below).
let split =
split && solids.iter().filter(|(item, _, _, f, _)| composite(item, f)).map(|(.., f, _)| f.len()).sum::<usize>() <= MAX_FACE_COMPOSITES;
let members: Vec<usize> = solids.iter().map(|s| s.4).collect();
// Which surfaces lie over an underlay of their facets, in one order or whole
// ([`in_one_order`], [`underlaid_surface`]).
let seals: Vec<bool> = solids.iter().map(|(.., f, _)| underlaid(f, colored)).collect();
let seal = |range: std::ops::Range<usize>| seals.get(range).is_some_and(|s| s.iter().any(|s| *s));
let surfaces: Vec<BuiltSurface> = solids
.into_iter()
.map(|(item, member, scopes, f, shape)| {
let paints = Paints::of(item, closed(shape));
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, split && shared.get(shape).copied().unwrap_or(false))
};
(depths, art, scopes, ends)
})
.collect();
let sealed = match fx.id.as_str() {
INFLATE => inflate_options(&p).cap,
CLASSIC_EXTRUDE => classic_extrude_options(&p).cap,
_ => extrude_options(&p).cap,
};
// One order takes a plain group of faces from each solid, one face for each facet. A lone
// solid's surface can instead be one clipped composite of all its facets ([`surface`]), which
// has no faces to order, so it draws whole.
let plain = |(depths, s, _, _): &BuiltSurface| matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len());
if masks {
// Each member's surfaces, in the scopes of its opacity mask or blending: its faces in one
// order when it is opaque and plain, otherwise each surface whole.
let mut out = vec![];
let mut surfaces = surfaces.into_iter().zip(members).enumerate().peekable();
while let Some((first, (s, member))) = surfaces.next() {
let mut run = vec![s];
while let Some((_, (s, _))) = surfaces.next_if(|(_, (_, m))| *m == member) {
run.push(s);
}
let flat = run.iter().all(|s| plain(s) && s.1.opacity >= 1.0 && s.1.blend == BlendMode::Normal && s.1.extras().mask.is_none());
let range = first..first + run.len();
if ordered(member) && flat {
let scopes = run.first().map_or(&[][..], |s| s.2);
let seals = seals.get(range).unwrap_or_default();
out.push((scopes, Arc::new(Node::group(n.id, in_one_order(n.id, run, first, &arts, sealed, seals)))));
} else {
let run = run.into_iter().zip(range).map(|((_, s, scopes, _), k)| (scopes, Arc::new(underlaid_surface(n.id, s, seal(k..k + 1)))));
out.extend(with_graphics(run.collect(), first, &arts));
}
}
return Some((masked(n.id, out), built));
}
if !opaque || !surfaces.iter().all(plain) {
let surfaces =
surfaces.into_iter().enumerate().map(|(k, (d, s, scopes, e))| (d, underlaid_surface(n.id, s, seal(k..k + 1)), scopes, e)).collect();
return Some((whole_surfaces(surfaces, &arts).into_iter().map(|(_, s)| s).collect(), built));
}
Some((vec![Arc::new(Node::group(n.id, in_one_order(n.id, surfaces, 0, &arts, sealed, &seals)))], built))
}
/// A plain `surface` ([`surface`]: a group of its facets) over an underlay of its facets when
/// `seal`, keeping the surface's opacity, blend mode and opacity mask.
fn underlaid_surface(id: NodeId, surface: Node, seal: bool) -> Node {
let NodeKind::Group { children, clip: false } = &surface.kind else { return surface };
let Some(mut out) = seal.then(|| crate::lighting::sealed(id, children)).flatten() else { return surface };
out.opacity = surface.opacity;
out.blend = surface.blend;
if let Some(mask) = surface.extras().mask.clone() {
out.extras_mut().mask = Some(mask);
}
out
}
/// The faces of `surfaces` (plain groups of their faces, [`surfaces_for`]'s from index `first`) in
/// one order ([`draw_order`], `sealed` for solids with caps), each of the graphics `arts` of those
/// surfaces right after the last face it is drawn on, of the surfaces that share it. A surface in a gradient with transparent stops
/// carries an opacity mask: its faces go in runs ([`in_runs`]), each carrying the mask, and each of
/// its graphics carries the mask too ([`with_graphics`]), so a graphic is hidden where its cap is.
/// The faces of the surfaces that `seals` marks lie over underlays of themselves
/// ([`underlaid_runs`], [`underlaid_surface`]).
fn in_one_order(id: NodeId, surfaces: Vec<BuiltSurface<'_>>, first: usize, arts: &[Placement], sealed: bool, seals: &[bool]) -> Vec<Arc<Node>> {
let masks: Vec<Option<Box<OpacityMask>>> = surfaces.iter().map(|(_, s, _, _)| s.extras().mask.clone()).collect();
let arts: Vec<&Placement> = arts.iter().filter(|(_, at, ..)| (first..first + surfaces.len()).contains(at)).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.into_iter().map(|end| end.map(|end| (surface, end))));
}
}
let order = draw_order(&placed, sealed);
// Each graphic goes right after the last of the faces it is drawn on, among the surfaces that
// share it ([`placed_graphics`]): those from the first of them to its own.
let last: Vec<Option<usize>> = arts
.iter()
.map(|(from, at, face, _)| {
let (from, at) = (from.saturating_sub(first), at - first);
order.iter().rposition(|k| ends.get(*k).copied().flatten().is_some_and(|(surface, end)| end == *face && (from..=at).contains(&surface)))
})
.collect();
let mut drawn: Vec<(usize, Arc<Node>)> = Vec::with_capacity(order.len() + arts.len());
for (at, k) in order.into_iter().enumerate() {
drawn.extend(faces.get_mut(k).and_then(Option::take));
drawn.extend(last.iter().zip(&arts).filter(|(l, _)| **l == Some(at)).map(|(_, (_, surface, _, art))| {
let mut art = art.clone();
if let Some(mask) = masks.get(surface - first).cloned().flatten() {
Arc::make_mut(&mut art).extras_mut().mask = Some(mask);
}
// An index past the surfaces: the graphic goes in a run of its own, with its own mask.
(masks.len(), art)
}));
}
let takes_underlay = |surface: usize| seals.get(surface) == Some(&true);
if masks.iter().any(Option::is_some) {
// The faces of a surface without a mask are sealed run by run before the masked surfaces'
// faces go in runs of their own, each holding one surface's faces, sealed with its mask.
let unmasked = underlaid_runs(id, drawn, |s| takes_underlay(s) && masks.get(s).is_some_and(Option::is_none));
return in_runs(id, unmasked, &masks)
.into_iter()
.map(|run| match &run.kind {
NodeKind::Group { clip: false, .. } if seals.iter().any(|s| *s) && run.extras().mask.is_some() => {
Arc::new(underlaid_surface(id, (*run).clone(), true))
}
_ => run,
})
.collect();
}
underlaid_runs(id, drawn, takes_underlay).into_iter().map(|(_, face)| face).collect()
}
/// `faces` in their drawing order, each with the index of its surface. Each run of consecutive
/// faces of one surface that `seal` takes lies over an underlay of its own faces, clipped to their
/// outline ([`crate::lighting::sealed`]), so no underlay reaches under another surface's faces or a
/// graphic drawn between them. A sealed run keeps its surface's index.
fn underlaid_runs(id: NodeId, faces: Vec<(usize, Arc<Node>)>, seal: impl Fn(usize) -> bool) -> Vec<(usize, Arc<Node>)> {
let mut out = Vec::with_capacity(faces.len());
let mut run: Vec<Arc<Node>> = vec![];
let mut faces = faces.into_iter().peekable();
while let Some((surface, face)) = faces.next() {
run.push(face);
if faces.peek().is_some_and(|(next, _)| *next == surface) {
continue;
}
match seal(surface).then(|| crate::lighting::sealed(id, &run)).flatten() {
Some(sealed) => {
out.push((surface, Arc::new(sealed)));
run.clear();
}
None => out.extend(run.drain(..).map(|face| (surface, face))),
}
}
out
}
/// `faces` in their back-to-front order, each with the index of its surface, where some surfaces
/// carry an opacity mask (`masks`, by surface). A face of a surface without one goes in the order
/// on its own. The faces of a masked surface share a run, one group with the surface's mask. Such a
/// face joins its surface's last run unless a face drawn after that run overlaps it: only then does
/// the surface split, where one group can't be both behind and in front of another face.
fn in_runs(id: NodeId, faces: Vec<(usize, Arc<Node>)>, masks: &[Option<Box<OpacityMask>>]) -> Vec<Arc<Node>> {
use crate::revolve::{Coverage, overlaps};
struct Run {
surface: Option<usize>,
bounds: Rect,
faces: Vec<Arc<Node>>,
cover: Vec<Coverage>,
}
let mut runs: Vec<Run> = vec![];
let mut last_run = vec![None; masks.len()];
// Past this many tests a face starts a run of its own, which never draws out of order.
let mut budget = crate::revolve::MAX_OVERLAP_TESTS;
for (surface, face) in faces {
let Some(cover) = Coverage::of(&face) else {
// A face with no outline and no box overlaps nothing.
runs.push(Run { surface: None, bounds: Rect::ZERO, faces: vec![face], cover: vec![] });
continue;
};
if masks.get(surface).is_none_or(Option::is_none) {
runs.push(Run { surface: None, bounds: cover.bounds, faces: vec![face], cover: vec![cover] });
continue;
}
let mut clear = |r: &Run| {
budget = budget.saturating_sub(1);
if !overlaps(r.bounds, cover.bounds) {
return budget > 0;
}
budget = budget.saturating_sub(r.cover.len());
budget > 0 && r.cover.iter().all(|c| !overlaps(c.bounds, cover.bounds) || !c.meets(&cover))
};
let joins = last_run.get(surface).copied().flatten().filter(|&i: &usize| runs.get(i + 1..).is_some_and(|later| later.iter().all(&mut clear)));
match joins.and_then(|i| runs.get_mut(i)) {
Some(run) => {
run.bounds = run.bounds.union(cover.bounds);
run.faces.push(face);
run.cover.push(cover);
}
None => {
if let Some(last) = last_run.get_mut(surface) {
*last = Some(runs.len());
}
runs.push(Run { surface: Some(surface), bounds: cover.bounds, faces: vec![face], cover: vec![cover] });
}
}
}
runs.into_iter()
.filter_map(|mut r| {
let Some(mask) = r.surface.and_then(|s| masks.get(s)).cloned().flatten() else { return r.faces.pop() };
let mut group = Node::group(id, r.faces);
group.extras_mut().mask = Some(mask);
Some(Arc::new(group))
})
.collect()
}
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx)
}
/// `n` drawn with its Extrude & Bevel, Inflate or Rotate effect: a group of shaded faces keeping
/// the object's id and transparency, the source path untouched. `None` without such an effect.
/// On a group or layer the effect builds a solid from each path, compound path and type in it,
/// all turned about one shared axis ([`members`]).
pub fn solid_art(n: &Node) -> Option<Node> {
solid_art_in(None, n)
}
/// [`solid_art`] with the symbols of `doc`: a symbol instance, a blend and a clipping group, alone
/// or in a group, build from the art they show ([`resolve`]). `None` too when that art can't be
/// resolved.
pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option<Node> {
if !has_solid(n) {
return None;
}
let n = &*resolve(doc, n, "").ok()?;
if is_container(n) {
return group_solid_art(doc, n);
}
let (path, rule) = profile(n)?;
let ctx = GeomContext::of(n);
let (sources, paints) = solid_paint_sources(n);
let mut children = vec![];
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(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);
} else {
let path = geometry(&path, &n.appearance.effects, &ctx);
remaining = crate::without_geometry(&n.appearance.effects);
// The object's Transform effects, applied to the path above, transform the items' raster
// effects too.
let mut paints = paints;
crate::raster::follow_paints(&mut paints, &n.appearance.effects);
// Every item's shadow goes behind every item's art.
let mut shadows = vec![];
for (source, item) in sources.into_iter().zip(&paints) {
if let Some((index, fx)) = solid(item.effects()) {
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(source.unwrap_or(item)));
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();
art
};
shadows.extend(shadow.into_iter().map(with_later));
children.extend(surfaces.into_iter().map(with_later));
} else {
let flat = Appearance { items: vec![item.clone()], ..Default::default() };
children.push(Arc::new(crate::group::shape_node(n.id, path.clone(), rule, flat)));
}
}
children.splice(0..0, shadows);
}
let mut art = n.clone();
art.kind = NodeKind::Group { children, clip: false };
art.appearance = Appearance { effects: remaining, ..Default::default() }.into();
Some(art)
}
/// [`solid_art`] of group or layer `n`: its object-level 3D effect on its members together. The
/// 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(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(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();
art.kind = NodeKind::Group { children, clip: false };
art.appearance = Appearance { effects: remaining, ..Default::default() }.into();
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, Rotate or Revolve. Revolve's frame is its unrolled side seen from the front, centered on
/// the origin, with no face maps: it sizes a new graphic and places no handles.
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 && is_three_d(&e.id)).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)?;
if fx.id == crate::REVOLVE {
// Revolve's graphics lie on its unrolled side (in a group, the side of the member with the
// longest profile): the frame is the part of it seen from the front, with no planar face
// to place a handle on.
let options = crate::revolve::revolve_options(&p);
let (w, h) = shapes
.iter()
.filter(|s| crate::revolve::surface_item(&s.items).is_some())
.filter_map(|s| vectorcraft_three_d::revolve_surface_size(&s.path, b, options))
.fold((0.0f64, 0.0f64), |best, size| if size.1 > best.1 { size } else { best });
if w <= 0.0 || h <= 0.0 {
return None;
}
return Some(graphics::GraphicFrame::new(Rect::new(-w / 2.0, -h / 2.0, w / 2.0, h / 2.0), None, None));
}
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
/// random ([`crate::raster::holds_back`]); the effects after it stay as they are.
pub(crate) fn past_effect(effects: &[Effect], index: usize) -> Vec<Effect> {
let (before, after) = effects.split_at(index.min(effects.len()));
let after = after.get(1..).unwrap_or_default();
let kept: Vec<Effect> = if after.iter().any(crate::raster::holds_back) {
before.iter().filter(|e| !is_geometry(&e.id)).cloned().collect()
} else {
crate::without_geometry(before)
};
kept.into_iter().chain(after.iter().cloned()).collect()
}
/// One solid of a 3D object as a mesh, with the color it is painted.
#[derive(Clone, Debug, PartialEq)]
pub struct SolidMesh {
pub mesh: Mesh,
/// The paint's display color (sRGB, 0..1) and its opacity: a gradient's stops averaged, gray
/// for a pattern.
pub color: [f32; 4],
}
/// The color a mesh of `paint` at `opacity` takes ([`SolidMesh::color`]).
pub(crate) fn mesh_color(paint: &Paint, opacity: f32) -> [f32; 4] {
let (rgb, alpha) = match paint {
Paint::Solid { color, .. } => (color.to_rgb(), 1.0),
Paint::Gradient(g) if !g.gradient.stops.is_empty() => {
let n = g.gradient.stops.len() as f32;
let mut sum = [0.0f32; 4];
for s in &g.gradient.stops {
let [r, gr, b] = s.color.to_rgb();
sum = [sum[0] + r, sum[1] + gr, sum[2] + b, sum[3] + s.opacity];
}
([sum[0] / n, sum[1] / n, sum[2] / n], sum[3] / n)
}
_ => ([0.5; 3], 1.0),
};
let unit = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
[unit(rgb[0]), unit(rgb[1]), unit(rgb[2]), unit(alpha * opacity)]
}
/// Add `mesh` to `out`, counting its triangles into `total`, or an error when that passes `limit`.
pub(crate) fn keep_mesh(out: &mut Vec<SolidMesh>, total: &mut usize, mesh: SolidMesh, limit: usize) -> Result<(), String> {
*total = total.saturating_add(mesh.mesh.triangles.len());
if *total > limit {
return Err("The 3D object is too complex to export".into());
}
out.push(mesh);
Ok(())
}
/// The solids effect `fx` builds from `shapes`, as [`art_for`] draws them, as meshes: Rotate turns
/// every fill and stroke; Extrude & Bevel and Inflate build one from each shape's top fill and one
/// from the outline of each stroke. A stroke whose solid is past the limits is left out. Each mesh
/// is counted against `limit` ([`keep_mesh`]) before the next is built.
fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) -> Result<Vec<SolidMesh>, String> {
let label = crate::effect_info(&fx.id).map_or("This effect", |e| e.label.trim_end_matches('…'));
let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
let p = crate::merged_params(&fx.id, &fx.params);
let view = view_options(&p);
let mut out = vec![];
let mut keep = |out: &mut Vec<SolidMesh>, mesh: Mesh, color: [f32; 4]| keep_mesh(out, total, SolidMesh { mesh, color }, limit);
if is_plane(&fx.id) {
for s in shapes {
for item in &s.items {
let (paint, opacity, _) = paint_of(item, s.opacity);
let (area, rule) = region(&s.path, s.rule, item);
keep(&mut out, vectorcraft_three_d::plane_mesh(&area, rule, b, view)?, mesh_color(&paint, opacity))?;
}
}
return Ok(out);
}
let (parts, bounds) = solid_parts(shapes, b);
// Inflate lays out every puff at once (they share one eye). Each Inflate and Extrude & Bevel
// solid is built once, as the loop reaches it: its facets decide whether the art draws it, as
// [`built_solids`] does, and its faces before projection make its mesh.
let mut inflated = (fx.id == INFLATE).then(|| {
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _, _)| (area, *rule)).collect();
vectorcraft_three_d::inflate_meshed(&shapes, bounds, inflate_options(&p), view)
});
let extrude = if fx.id == CLASSIC_EXTRUDE { classic_extrude_options(&p) } else { extrude_options(&p) };
let mut kept = 0usize;
for (item, member, area, rule, _, _) in &parts {
let item: &AppearanceItem = item;
#[cfg(test)]
SOLIDS_BUILT.with(|n| n.set(n.get() + 1));
let built = match inflated.as_mut() {
Some(each) => each.next().unwrap_or_else(|| Ok(MeshedSolid::default())),
None => vectorcraft_three_d::extrude_meshed(area, *rule, bounds, extrude, view),
};
// A stroke the art leaves out is left out of the meshes too. The faces of the solids kept
// count together against the object's limit.
let solid = match built {
Ok(solid) => solid,
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => continue,
Err(e) => return Err(e),
};
kept = kept.saturating_add(solid.facets.len());
if kept > MAX_OBJECT_FACES {
return Err(vectorcraft_three_d::TOO_COMPLEX_SOLID.to_string());
}
match solid.mesh() {
Ok(mesh) => {
let (paint, opacity, _) = paint_of(item, *member);
keep(&mut out, mesh, mesh_color(&paint, opacity))?;
}
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {}
Err(e) => return Err(e),
}
}
Ok(out)
}
/// The solids `n`'s Extrude & Bevel, Inflate, Rotate or Revolve effect builds, as meshes with the
/// colors they are painted ([`SolidMesh`]), faces turned away from the viewer included; meshes
/// with no triangles are left out. On a group or layer, the effect's solids of its members
/// ([`members`]). The object's opacity multiplies every color's. An error when `n` carries no
/// such effect, its solids can't be built, none has a triangle, or together they have more than
/// [`MAX_MESH_TRIANGLES`].
pub fn three_d_meshes(n: &Node) -> Result<Vec<SolidMesh>, String> {
three_d_meshes_in(None, n)
}
/// [`three_d_meshes`] with the symbols of `doc`: a symbol instance, a blend and a clipping group
/// build from the art they show ([`resolve`]).
pub fn three_d_meshes_in(doc: Option<&Document>, n: &Node) -> Result<Vec<SolidMesh>, String> {
meshes_within(doc, n, &mut 0, MAX_MESH_TRIANGLES)
}
/// The meshes of every object in `nodes` ([`three_d_meshes`]), in order, as one 3D asset exports
/// them: the objects share one budget of [`MAX_MESH_TRIANGLES`].
pub fn three_d_meshes_of<'a>(nodes: impl IntoIterator<Item = &'a Node>) -> Result<Vec<SolidMesh>, String> {
three_d_meshes_of_in(None, nodes)
}
/// [`three_d_meshes_of`] with the symbols of `doc` ([`three_d_meshes_in`]).
pub fn three_d_meshes_of_in<'a>(doc: Option<&Document>, nodes: impl IntoIterator<Item = &'a Node>) -> Result<Vec<SolidMesh>, String> {
meshes_of_within(doc, nodes, MAX_MESH_TRIANGLES)
}
/// [`three_d_meshes_of_in`] with a budget of `limit` triangles.
fn meshes_of_within<'a>(doc: Option<&Document>, nodes: impl IntoIterator<Item = &'a Node>, limit: usize) -> Result<Vec<SolidMesh>, String> {
let mut total = 0usize;
let mut out = vec![];
for n in nodes {
out.extend(meshes_within(doc, n, &mut total, limit)?);
}
Ok(out)
}
/// [`three_d_meshes_in`], with the triangles of every solid added to `total` as it is built and an
/// error as soon as `total` passes `limit`.
fn meshes_within(doc: Option<&Document>, n: &Node, total: &mut usize, limit: usize) -> Result<Vec<SolidMesh>, String> {
if !crate::has_revolve(n) && !has_solid(n) {
return Err("the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate)".into());
}
let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id));
let n = &*resolve(doc, n, label)?;
let meshes = if crate::has_revolve(n) {
crate::revolve::revolve_meshes(n, total, limit)?
} else if !has_solid(n) {
return Err("the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate)".into());
} else if is_container(n) {
let (index, fx) = solid(&n.appearance.effects).ok_or("a 3D effect on a group's fill or stroke draws nothing to export")?;
let label = crate::effect_info(&fx.id).map_or("This effect", |e| e.label.trim_end_matches('…'));
let mut shapes = members(n, label)?;
reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes);
meshes_for(fx, &shapes, total, limit)?
} else {
let (path, rule) = object_profile(n, label)?;
let ctx = GeomContext::of(n);
let (sources, paints) = solid_paint_sources(n);
if let Some((index, fx)) = solid(&n.appearance.effects) {
let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx);
meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }], total, limit)?
} else {
let path = geometry(&path, &n.appearance.effects, &ctx);
let mut out = vec![];
for (source, item) in sources.into_iter().zip(&paints) {
if let Some((index, fx)) = solid(item.effects()) {
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(source.unwrap_or(item)));
out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }], total, limit)?);
}
}
out
}
};
let meshes: Vec<SolidMesh> = meshes
.into_iter()
.filter(|m| !m.mesh.is_empty())
.map(|mut m| {
m.color[3] *= n.opacity.clamp(0.0, 1.0);
m
})
.collect();
if meshes.is_empty() {
return Err("its 3D effect builds no solid".into());
}
Ok(meshes)
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_mesh;
#[cfg(test)]
mod tests_sources;
#[cfg(test)]
mod tests_shadow;
#[cfg(test)]
mod tests_revolve_order;