mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 22:22:25 +00:00
901 lines
43 KiB
Rust
901 lines
43 KiB
Rust
//! Extrude & Bevel, Inflate, Rotate and Extrude & Bevel (Classic) as live, shaded vector art,
|
|
//! shared by the canvas and every exporter. Like Revolve, the source path stays editable and the effect draws its 3D faces.
|
|
use std::sync::Arc;
|
|
|
|
use serde_json::Value;
|
|
use vectorcraft_doc::color::{BlendMode, Paint};
|
|
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask, StrokeLayer};
|
|
use vectorcraft_geom::{FillRule, PathData, Rect};
|
|
use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, Mesh, View};
|
|
|
|
#[cfg(test)]
|
|
use vectorcraft_doc::color::Color;
|
|
|
|
use crate::util::{flag, num, text};
|
|
use crate::{GeomContext, apply_geometry_with, is_geometry};
|
|
|
|
mod classic;
|
|
pub use classic::{CLASSIC_EXTRUDE, SHADING_COLORS, classic_extrude_options, classic_shading};
|
|
|
|
mod source;
|
|
pub(crate) use source::{resolve, resolve_for};
|
|
|
|
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; 4] = [EXTRUDE, INFLATE, ROTATE, CLASSIC_EXTRUDE];
|
|
|
|
/// Is `id` one of the 3D and Materials effects (Revolve included)?
|
|
pub fn is_three_d(id: &str) -> bool {
|
|
id == crate::REVOLVE || SOLIDS.contains(&id)
|
|
}
|
|
|
|
/// 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));
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> {
|
|
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),
|
|
}
|
|
}
|
|
|
|
/// Check solid `id`'s parameter values `p` alone: the part of [`validate_solid`] that does not
|
|
/// need the object.
|
|
fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> {
|
|
if id == CLASSIC_EXTRUDE {
|
|
classic::validate(p)?;
|
|
}
|
|
crate::lighting::validate(p)?;
|
|
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_params(id, p)?;
|
|
let label = label_of(id);
|
|
let n = &*source::resolve_for(doc, n, label, id == ROTATE)?;
|
|
if is_container(n) {
|
|
let shapes = members(n, label)?;
|
|
let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?;
|
|
return shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ()));
|
|
}
|
|
let (path, rule) = object_profile(n, label)?;
|
|
let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
|
|
facets(id, &path, rule, b, p).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
|
|
}
|
|
}
|
|
|
|
/// `surfaces`, each with the scopes of the member it was built from, in groups with the opacity
|
|
/// masks of those scopes, so each mask 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| s.mask.is_some()).map(|s| Scope { opacity: 1.0, blend: BlendMode::Normal, ..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?
|
|
fn has_masks(shapes: &[Profile]) -> bool {
|
|
shapes.iter().any(|s| s.scopes.iter().any(|s| s.mask.is_some()))
|
|
}
|
|
|
|
/// 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 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 || matches!(c.kind, NodeKind::Path { guide: true, .. }) {
|
|
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> {
|
|
if id == crate::REVOLVE { crate::revolve::validate_revolve_in(doc, n, 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` (the effects' length for a new effect), or on the object itself
|
|
/// when `at` is `None`. 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> {
|
|
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 id == crate::REVOLVE => crate::revolve::validate_item_revolve(n, p, item, index),
|
|
_ => validate_three_d_in(doc, n, id, p),
|
|
}
|
|
}
|
|
|
|
/// Are 3D effect `id`'s parameter values `p` valid? The checks of [`validate_three_d`] that do not
|
|
/// build the art, for an edit that hides the effect: hidden, it draws nothing, so its object does
|
|
/// not need to fit.
|
|
pub fn validate_three_d_params(id: &str, p: &Value) -> Result<(), String> {
|
|
if id == crate::REVOLVE { crate::revolve::validate_revolve_params(p) } else { validate_solid_params(id, p) }
|
|
}
|
|
|
|
/// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and
|
|
/// blend mode (so overlapping facets don't add up their transparency).
|
|
fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: BlendMode, colored: bool) -> Node {
|
|
if colored && paint.color().is_some() && facets.iter().any(|face| face.rgb_gradient.is_some()) {
|
|
let mut group = crate::lighting::solid_surface(n.id, &facets, paint);
|
|
group.opacity = opacity;
|
|
group.blend = blend;
|
|
return group;
|
|
}
|
|
// Preserve existing white-light paints, alpha composition and seam joins 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. No wider than the facet is thick, though: seen
|
|
// at a grazing angle a facet can be thinner than the stroke, and its stroke would then
|
|
// paint over its neighbours.
|
|
let seam = (face.thickness() / 2.0).clamp(0.05, 0.75);
|
|
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 = StrokeLayer::new(fill.paint.clone(), seam);
|
|
if colored {
|
|
edge.join = vectorcraft_doc::LineJoin::Round;
|
|
}
|
|
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
|
|
}
|
|
|
|
/// `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,
|
|
}
|
|
}
|
|
|
|
/// Paint copies `surface` makes for `facets`: two per facet (fill and seam stroke), six when colored
|
|
/// lights give each facet derived paints (`lighting::solid_surface`).
|
|
fn facet_copies(facets: &[Facet], paint: &Paint, colored: bool) -> usize {
|
|
let derived = colored && paint.color().is_some() && facets.iter().any(|f| f.rgb_gradient.is_some());
|
|
facets.len().saturating_mul(if derived { 6 } else { 2 })
|
|
}
|
|
|
|
/// 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, and its shape's scopes.
|
|
type Part<'a> = (&'a AppearanceItem, f32, PathData, FillRule, &'a [Scope]);
|
|
|
|
/// 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()
|
|
.flat_map(|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(|(_, i)| {
|
|
let (area, rule) = region(&s.path, s.rule, i);
|
|
(i, s.opacity, area, rule, s.scopes.as_slice())
|
|
})
|
|
})
|
|
.collect();
|
|
let bounds = parts.iter().filter_map(|(_, _, area, _, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(fallback);
|
|
(parts, bounds)
|
|
}
|
|
|
|
/// `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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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 and no colored light makes
|
|
/// a surface one composite or masks it, their faces are drawn in one back-to-front order (where
|
|
/// depths tie, as for the caps turned toward the viewer, in the order of the appearance);
|
|
/// otherwise each solid is a surface of its own, in that order. A member's opacity mask, or that of
|
|
/// a group it is in, masks the surfaces of the members it holds ([`masked`]).
|
|
fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>> {
|
|
let b = union_bounds(shapes)?;
|
|
let p = crate::merged_params(&fx.id, &fx.params);
|
|
let classic = fx.id == CLASSIC_EXTRUDE;
|
|
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;
|
|
if fx.id == ROTATE {
|
|
// Every part turns about the centre of the object's own bounds (a group's members about
|
|
// the centre of theirs), so they stay together.
|
|
let mut out = vec![];
|
|
for s in shapes {
|
|
for item in &s.items {
|
|
let (paint, opacity, blend) = paint_of(item, s.opacity);
|
|
let (area, rule) = region(&s.path, s.rule, item);
|
|
let f = facets(ROTATE, &area, rule, b, &p).ok()?;
|
|
if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, &paint, colored), &paint) {
|
|
return None;
|
|
}
|
|
out.push((s.scopes.as_slice(), Arc::new(surface(n, f, &paint, opacity, blend, colored))));
|
|
}
|
|
}
|
|
return Some(if masks { masked(n.id, out) } else { out.into_iter().map(|(_, s)| s).collect() });
|
|
}
|
|
let (parts, bounds) = solid_parts(shapes, b);
|
|
let built = if fx.id == INFLATE {
|
|
// One eye for every puff, as far away as the highest needs.
|
|
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _)| (area, *rule)).collect();
|
|
vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p))
|
|
} else {
|
|
parts.iter().map(|(_, _, area, rule, _)| facets(&fx.id, area, *rule, bounds, &p)).collect()
|
|
};
|
|
let mut solids = vec![];
|
|
for ((item, opacity, _, _, scopes), f) in parts.iter().zip(built) {
|
|
let item: &AppearanceItem = item;
|
|
match f {
|
|
Ok(f) if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, paint_ref(item), colored), paint_ref(item)) => return None,
|
|
Ok(f) => solids.push((item, *opacity, *scopes, f)),
|
|
// A stroke's outline past the limits is left out; the fill's solid still draws.
|
|
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {}
|
|
// A loaded file or a shape edited past the limits 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 surfaces: Vec<(Vec<f64>, Node, &[Scope])> = solids
|
|
.into_iter()
|
|
.map(|(item, member, scopes, f)| {
|
|
let depths: Vec<f64> = f.iter().map(|f| f.depth).collect();
|
|
let (paint, opacity, blend) = paint_of(item, member);
|
|
let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) };
|
|
(depths, art, scopes)
|
|
})
|
|
.collect();
|
|
if masks {
|
|
return Some(masked(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, Arc::new(s))).collect()));
|
|
}
|
|
// One order takes a plain group of faces from each solid, one face for each facet. With
|
|
// colored lights a surface can instead be one clipped composite of all its facets, or carry an
|
|
// opacity mask, and then it stays whole.
|
|
let plain = surfaces.iter().all(|(depths, s, _)| {
|
|
s.extras().mask.is_none() && matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())
|
|
});
|
|
if !opaque || !plain {
|
|
return Some(surfaces.into_iter().map(|(_, s, _)| Arc::new(s)).collect());
|
|
}
|
|
let mut faces = vec![];
|
|
for (depths, s, _) in surfaces {
|
|
if let NodeKind::Group { children, .. } = s.kind {
|
|
faces.extend(depths.into_iter().zip(children));
|
|
}
|
|
}
|
|
// A stable sort: faces at the same depth keep the order of the appearance.
|
|
faces.sort_by(|a, b| a.0.total_cmp(&b.0));
|
|
Some(vec![Arc::new(Node::group(n.id, faces.into_iter().map(|(_, face)| face).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(n);
|
|
}
|
|
let (path, rule) = profile(n)?;
|
|
let ctx = GeomContext::of(n);
|
|
let paints = solid_paints(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);
|
|
children.extend(art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?);
|
|
// Later geometry effects act on the projected art as a whole.
|
|
remaining = n
|
|
.appearance
|
|
.effects
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index))
|
|
.map(|(_, e)| e.clone())
|
|
.collect();
|
|
} else {
|
|
let path = geometry(&path, &n.appearance.effects, &ctx);
|
|
remaining = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
|
|
for item in &paints {
|
|
if let Some((index, fx)) = solid(item.effects()) {
|
|
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item));
|
|
for mut art in art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])? {
|
|
Arc::make_mut(&mut art).appearance.effects =
|
|
item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect();
|
|
children.push(art);
|
|
}
|
|
} else {
|
|
children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![item.clone()], ..Default::default() })));
|
|
}
|
|
}
|
|
}
|
|
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(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 children = art_for(n, fx, &shapes)?;
|
|
let remaining =
|
|
n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect();
|
|
let mut art = n.clone();
|
|
art.kind = NodeKind::Group { children, clip: false };
|
|
art.appearance = Appearance { effects: remaining, ..Default::default() }.into();
|
|
Some(art)
|
|
}
|
|
|
|
/// 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 fx.id == ROTATE {
|
|
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 and its mesh are built as the loop reaches them.
|
|
let mut inflated = (fx.id == INFLATE).then(|| {
|
|
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _)| (area, *rule)).collect();
|
|
vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view)
|
|
});
|
|
let extrude = extrude_options(&p);
|
|
for (item, member, area, rule, _) in &parts {
|
|
let item: &AppearanceItem = item;
|
|
let mesh = match inflated.as_mut() {
|
|
Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())),
|
|
None => vectorcraft_three_d::extrude_mesh(area, *rule, bounds, extrude, view),
|
|
};
|
|
match 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 paints = solid_paints(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 item in &paints {
|
|
if let Some((index, fx)) = solid(item.effects()) {
|
|
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(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;
|