mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 11:17:01 -06:00
The public 3D and Materials panel options list Depth, Twist, Taper and Cap for every 3D type; Inflate had only Depth. - Twist (0° to 360°) and Taper (1–100 %) turn and scale the back about the shape's centre, as Extrude & Bevel's do: a puffed back is turned and lowered in proportion, and the sides follow in slices. - Cap: Solid closes the back; Hollow leaves it off, so the inflated front is a shell open behind. The documentation doesn't say what Cap means for Inflate; this reading is stated in docs/three-d.md. - The dialog's Object tab shows them in the documented order (Depth, Twist, Taper, Cap), then Volume and Both sides. At 0 % volume a twisted, tapered Inflate has the extrusion's outline from every side (tested). A twisted, tapered, hollow Inflate draws on the canvas exactly as it exports, and expands to faces with their own ids (tested). ROADMAP and the parity pages list the new options; the 3D score stays at ~25 %.
270 lines
12 KiB
Rust
270 lines
12 KiB
Rust
//! Extrude & Bevel, Inflate and Rotate 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, Color, Paint};
|
|
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer};
|
|
use vectorcraft_geom::{FillRule, PathData, Rect};
|
|
use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, View};
|
|
|
|
use crate::util::{flag, num, text};
|
|
use crate::{GeomContext, apply_geometry_with, is_geometry};
|
|
|
|
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; 3] = [EXTRUDE, INFLATE, ROTATE];
|
|
|
|
/// 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)
|
|
}
|
|
|
|
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, "perspective", 0.0) / 100.0,
|
|
light_azimuth: num(p, "lightAzimuth", -45.0),
|
|
light_elevation: num(p, "lightElevation", 45.0),
|
|
light_intensity: num(p, "lightIntensity", 80.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),
|
|
}),
|
|
}
|
|
}
|
|
|
|
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.
|
|
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))
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn facets(id: &str, path: &PathData, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> {
|
|
let view = view_options(p);
|
|
match id {
|
|
EXTRUDE => vectorcraft_three_d::extrude(path, bounds, extrude_options(p), view),
|
|
INFLATE => vectorcraft_three_d::inflate(path, bounds, inflate_options(p), view),
|
|
_ => vectorcraft_three_d::plane(path, bounds, view),
|
|
}
|
|
}
|
|
|
|
/// 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> {
|
|
let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…'));
|
|
let (path, _) =
|
|
profile(n).ok_or_else(|| format!("{label} currently requires a path or compound path. Convert type, images and groups to paths first."))?;
|
|
let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
|
|
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(", ")));
|
|
}
|
|
facets(id, &path, b, p).map(|_| ())
|
|
}
|
|
|
|
/// 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) -> Node {
|
|
let children = facets
|
|
.into_iter()
|
|
.map(|face| {
|
|
let mut items = vec![];
|
|
if let Some(c) = paint.color() {
|
|
let [r, g, b] = c.to_rgb();
|
|
let s = face.brightness as f32;
|
|
items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(r * s, g * s, b * s)))));
|
|
} else {
|
|
// Gradients and patterns keep their paint, darkened by a black layer.
|
|
items.push(AppearanceItem::Fill(FillLayer::new(paint.clone())));
|
|
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
|
|
shadow.opacity = (1.0 - face.brightness) as f32;
|
|
items.push(AppearanceItem::Fill(shadow));
|
|
}
|
|
// 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);
|
|
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);
|
|
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;
|
|
group
|
|
}
|
|
|
|
fn paint_of(item: &AppearanceItem) -> (Paint, f32, BlendMode) {
|
|
match item {
|
|
AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity, f.blend),
|
|
AppearanceItem::Stroke(s) => (s.paint.clone(), s.opacity, s.blend),
|
|
}
|
|
}
|
|
|
|
/// The area `item` paints on `path`: the path itself for a fill, the stroke's outline for a stroke.
|
|
fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> PathData {
|
|
match item {
|
|
AppearanceItem::Stroke(s) if s.width > 0.0 && s.width.is_finite() => crate::stroke::outline_region(path, rule, s),
|
|
_ => path.clone(),
|
|
}
|
|
}
|
|
|
|
/// The 3D art of one effect `fx` on `path` (already through the effects before it).
|
|
/// Extrude & Bevel and Inflate build a solid from the outline, painted with the top visible fill
|
|
/// (or, without one, the top stroke); Rotate turns every fill and stroke, each in its own paint.
|
|
fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&AppearanceItem]) -> Option<Vec<Arc<Node>>> {
|
|
let b = path.bounds()?;
|
|
let p = crate::merged_params(&fx.id, &fx.params);
|
|
if fx.id == ROTATE {
|
|
// Every part turns about the centre of the object's own bounds, so they stay together.
|
|
let mut out = vec![];
|
|
for item in items {
|
|
let (paint, opacity, blend) = paint_of(item);
|
|
let f = facets(ROTATE, ®ion(path, rule, item), b, &p).ok()?;
|
|
out.push(Arc::new(surface(n, f, &paint, opacity, blend)));
|
|
}
|
|
return Some(out);
|
|
}
|
|
let item = items.iter().rev().find(|i| matches!(i, AppearanceItem::Fill(_))).or_else(|| items.last())?;
|
|
let (paint, opacity, blend) = paint_of(item);
|
|
// 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.
|
|
let f = facets(&fx.id, path, b, &p).ok()?;
|
|
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend))])
|
|
}
|
|
|
|
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.
|
|
pub fn solid_art(n: &Node) -> Option<Node> {
|
|
if !has_solid(n) {
|
|
return None;
|
|
}
|
|
let (path, rule) = profile(n)?;
|
|
let ctx = GeomContext::of(n);
|
|
let painted: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| crate::paints(i)).collect();
|
|
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, &path, rule, &painted)?);
|
|
// 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 &painted {
|
|
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, &p, rule, &[*item])? {
|
|
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)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "tests_solids.rs"]
|
|
mod tests;
|