mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 12:17:14 -06:00
The three 3D types besides Revolve, as live effects in the three-d crate, drawn as shaded vector faces by the canvas, SVG/PDF export and Expand Appearance alike, from the public 3D and Materials documentation: - Extrude & Bevel: depth (0-2000 pt), twist, taper, solid or hollow caps, and bevels in six original cross-sections (classic, rounded, cove, step, ogee) with width, height, repeat, spacing, inside and both sides. - Inflate: the shape as a membrane under pressure (a Poisson solve held at zero along the outline and holes), so it rises vertically from the edge and rounds without creases; volume, depth and both sides. Steep cells near the outline are refined without cracks. - Rotate: every fill and stroke turned in 3D and shaded as one plane. All three share Revolve's view and lighting parameters, the rotation and light gizmos (turning about the art's centre) and a dialog with Object, Rotation and Lighting tabs, with Position presets (Front ... Isometric Bottom, our own values). An object takes one 3D effect. Inflate cuts the grid cells that straddle a hole's edge with pathops, hence the new pathops -> three-d layer order. Docs: docs/three-d.md, mcp.md, and the 3D rows of the parity docs.
263 lines
12 KiB
Rust
263 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) }
|
|
}
|
|
|
|
/// 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;
|