mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 05:55:32 +00:00
Merge pull request #6980 from addibble/seam-round-joins
3D effects: every facet's seam has round joins
This commit is contained in:
@@ -96,6 +96,18 @@ pub(crate) fn rgb_gradient_art(
|
||||
composed_art(id, path, paint, Ramp::Rgb(light), 0.75)
|
||||
}
|
||||
|
||||
/// The narrow stroke of a facet's own `paint`, `width` wide, that closes the hairline seam between
|
||||
/// it and its neighbors where each is antialiased alone. Its joins are round: a facet seen almost
|
||||
/// edge-on is a sharp sliver, and a mitered seam would reach far past its tip as a thorn, where a
|
||||
/// round one reaches no more than half its width past the facet. Revolve's meets() counts on a
|
||||
/// stroked seam reaching no more than half its width; faces drawn as clipped groups (whose clip,
|
||||
/// in `composed_art`, already outlines the seam with round joins) are measured by their box.
|
||||
pub(crate) fn seam_stroke(paint: Paint, width: f64) -> vectorcraft_doc::StrokeLayer {
|
||||
let mut edge = vectorcraft_doc::StrokeLayer::new(paint, width);
|
||||
edge.join = vectorcraft_doc::LineJoin::Round;
|
||||
edge
|
||||
}
|
||||
|
||||
/// Smooth Inflate triangles keep the seam within their narrow projected footprint.
|
||||
pub(crate) fn facet_art(
|
||||
id: vectorcraft_doc::NodeId,
|
||||
@@ -109,8 +121,7 @@ pub(crate) fn facet_art(
|
||||
let light = vectorcraft_three_d::LightGradient { diffuse: r, ambient: 0.0, color: [1.0; 3] };
|
||||
if let Some(color) = paint.color() {
|
||||
let paint = smooth_paint(light, color);
|
||||
let mut edge = vectorcraft_doc::StrokeLayer::new(paint.clone(), seam);
|
||||
edge.join = vectorcraft_doc::LineJoin::Round;
|
||||
let edge = seam_stroke(paint.clone(), seam);
|
||||
return vectorcraft_doc::Node::path(
|
||||
id,
|
||||
path.clone(),
|
||||
@@ -131,7 +142,7 @@ pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_t
|
||||
use kurbo::Shape;
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_doc::color::BlendMode;
|
||||
use vectorcraft_doc::{Appearance, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, Node, NodeKind};
|
||||
use vectorcraft_geom::{BezPath, PathData, Rect, shapes};
|
||||
|
||||
let mut boundary = BezPath::new();
|
||||
@@ -163,8 +174,7 @@ pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_t
|
||||
} else {
|
||||
Paint::solid(shade(Color::WHITE, color.map(|c| c * face.illumination[channel])))
|
||||
};
|
||||
let mut edge = StrokeLayer::new(paint.clone(), (face.thickness() / 2.0).clamp(0.05, 0.75));
|
||||
edge.join = vectorcraft_doc::LineJoin::Round;
|
||||
let edge = seam_stroke(paint.clone(), (face.thickness() / 2.0).clamp(0.05, 0.75));
|
||||
Arc::new(Node::path(
|
||||
id,
|
||||
face.path(),
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, LineJoin, Node, NodeId, NodeKind, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2};
|
||||
use vectorcraft_three_d::{Edge, Revolve};
|
||||
|
||||
@@ -494,10 +494,10 @@ fn surface(
|
||||
.into_iter()
|
||||
.flat_map(|item| {
|
||||
let AppearanceItem::Fill(fill) = &item else { return vec![item] };
|
||||
let mut edge = StrokeLayer::new(fill.paint.clone(), SEAM_WIDTH);
|
||||
// Acute face corners must stay within the overlap width. Miter joins
|
||||
// amplify this seam stroke into visible spikes along the silhouette.
|
||||
edge.join = LineJoin::Round;
|
||||
// Acute face corners must stay within the overlap width: meets() counts on a
|
||||
// stroked seam reaching no more than half its width; faces drawn as clipped
|
||||
// groups are measured by their box.
|
||||
let mut edge = crate::lighting::seam_stroke(fill.paint.clone(), SEAM_WIDTH);
|
||||
edge.opacity = fill.opacity;
|
||||
vec![item, AppearanceItem::Stroke(edge)]
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ 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_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, PlanarFace, View};
|
||||
@@ -530,7 +530,7 @@ fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: Ble
|
||||
group.blend = blend;
|
||||
return group;
|
||||
}
|
||||
// Preserve existing white-light paints, alpha composition and seam joins exactly.
|
||||
// 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
|
||||
@@ -551,10 +551,7 @@ fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: Ble
|
||||
.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;
|
||||
}
|
||||
let mut edge = crate::lighting::seam_stroke(fill.paint.clone(), seam);
|
||||
edge.opacity = fill.opacity;
|
||||
vec![item, AppearanceItem::Stroke(edge)]
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node};
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_three_d::{Bevel, BevelShape, ClassicShading, ClassicSurface, Extrude, Facet, Light};
|
||||
|
||||
@@ -207,8 +207,8 @@ pub(crate) fn surface(n: &Node, facets: Vec<Facet>, source: &Paint, opacity: f32
|
||||
.into_iter()
|
||||
.map(|face| {
|
||||
if wire {
|
||||
let mut edge = StrokeLayer::new(paint.clone(), WIRE);
|
||||
edge.join = vectorcraft_doc::LineJoin::Round;
|
||||
// A wire edge is drawn as a seam is: a round-joined stroke of the paint.
|
||||
let edge = crate::lighting::seam_stroke(paint.clone(), WIRE);
|
||||
return Arc::new(Node::path(n.id, face.path(), Appearance { items: vec![AppearanceItem::Stroke(edge)], ..Default::default() }));
|
||||
}
|
||||
let (tone, spec) = shading.tone(face.normal, &lights);
|
||||
@@ -218,18 +218,23 @@ pub(crate) fn surface(n: &Node, facets: Vec<Facet>, source: &Paint, opacity: f32
|
||||
// Unshaded (No Shading, no shading color, or a face in full light) with no
|
||||
// highlight: the paint itself, its color space and swatch kept.
|
||||
Some(_) if (tone >= 1.0 || shade.is_none()) && spec <= 0.0 => {
|
||||
vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(StrokeLayer::new(paint.clone(), seam))]
|
||||
vec![
|
||||
AppearanceItem::Fill(FillLayer::new(paint.clone())),
|
||||
AppearanceItem::Stroke(crate::lighting::seam_stroke(paint.clone(), seam)),
|
||||
]
|
||||
}
|
||||
Some(c) => {
|
||||
let fill = Paint::solid(shaded(c, tone, spec, shade));
|
||||
vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(StrokeLayer::new(fill, seam))]
|
||||
vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(crate::lighting::seam_stroke(fill, seam))]
|
||||
}
|
||||
// Gradients and patterns keep their paint, darkened by a layer of the shading color
|
||||
// and lightened by one of white. As with the other 3D effects' pattern shadow, the
|
||||
// layers cover the whole face, transparent pattern cells included.
|
||||
None => {
|
||||
let mut items =
|
||||
vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(StrokeLayer::new(paint.clone(), seam))];
|
||||
let mut items = vec![
|
||||
AppearanceItem::Fill(FillLayer::new(paint.clone())),
|
||||
AppearanceItem::Stroke(crate::lighting::seam_stroke(paint.clone(), seam)),
|
||||
];
|
||||
for (color, share) in [(shade, 1.0 - tone), (Some(Color::WHITE), spec)] {
|
||||
if let Some(color) = color.filter(|_| share > 0.0) {
|
||||
let mut layer = FillLayer::new(Paint::solid(color));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Document, NodeId};
|
||||
use vectorcraft_doc::{Document, NodeId, StrokeLayer};
|
||||
use vectorcraft_geom::{Affine, BezPath, Point, Shape};
|
||||
|
||||
use super::*;
|
||||
@@ -1293,6 +1293,9 @@ fn inflate_reads_twist_taper_and_cap() {
|
||||
assert_eq!(crate::default_params(INFLATE).unwrap()["cap"], json!(true));
|
||||
}
|
||||
|
||||
/// Inflate under the legacy white light keeps the polygon faces it had before the colored-light
|
||||
/// batching (each face a path of its own, not a channel group) and its seam's 0.05–0.75 pt range;
|
||||
/// its seams' joins are round, as every 3D seam's.
|
||||
#[test]
|
||||
fn legacy_white_inflate_keeps_polygon_faces_and_its_seam_range() {
|
||||
let mut n = square_node();
|
||||
@@ -1305,7 +1308,9 @@ fn legacy_white_inflate_keeps_polygon_faces_and_its_seam_range() {
|
||||
assert!(matches!(face.kind, NodeKind::Path { .. }));
|
||||
let AppearanceItem::Stroke(stroke) = &face.appearance.items[1] else { panic!("seam") };
|
||||
assert!((0.05..=0.75).contains(&stroke.width));
|
||||
assert_eq!(stroke.join, vectorcraft_doc::LineJoin::Miter);
|
||||
// Round, as every seam is: a facet seen almost edge-on is a sliver, and a mitered seam
|
||||
// reached past the solid as a thorn.
|
||||
assert_eq!(stroke.join, vectorcraft_doc::LineJoin::Round);
|
||||
}
|
||||
// A tiny shape exercises the lower bound, which otherwise disappears at grazing angles.
|
||||
n.transform(Affine::scale(0.001), false);
|
||||
@@ -1697,3 +1702,248 @@ fn classic_rotate_parameters_are_checked() {
|
||||
assert!(err.starts_with(says), "{p}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Under a white light and under colored ones, in Extrude & Bevel, Inflate, Rotate, Extrude &
|
||||
/// Bevel (Classic, each of its shading branches) and Rotate (Classic, its No Shading and Diffuse
|
||||
/// surfaces and a gradient), a face seen almost edge-on (a sharp sliver) has
|
||||
/// its seam stroke rounded at the corners: every seam's joins are round, and none reaches past its
|
||||
/// face more than half its width as it is outlined (a mitered seam would reach past the solid as a
|
||||
/// thorn up to ten times that beyond the face's tip). Each case's view makes some face with a
|
||||
/// corner a miter would spike from.
|
||||
#[test]
|
||||
fn a_faces_seam_stays_within_half_its_width_of_the_face() {
|
||||
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
|
||||
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(
|
||||
GradientKind::Linear,
|
||||
vec![GradientStop::new(0.0, RED), GradientStop::new(1.0, BLUE)],
|
||||
))));
|
||||
// Does `path` have a convex corner sharper than 45° that a miter with `limit` would spike from
|
||||
// (one sharper than 2·asin(1/limit) is bevelled instead, so it can't thorn; a reflex corner's
|
||||
// miter points inward)? An open subpath's ends are no corners, and a closing anchor repeating
|
||||
// the first is one corner.
|
||||
fn acute(path: &PathData, limit: f64) -> bool {
|
||||
let floor = 2.0 * (1.0 / limit).asin();
|
||||
path.subpaths.iter().any(|sp| {
|
||||
let mut pts: Vec<Point> = sp.anchors.iter().map(|a| a.p).collect();
|
||||
pts.dedup_by(|a, b| a.distance(*b) < 1e-9);
|
||||
if sp.closed && pts.len() > 1 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.distance(*b) < 1e-9) {
|
||||
pts.pop();
|
||||
}
|
||||
let n = pts.len();
|
||||
let corners: Vec<usize> = if sp.closed { (0..n).collect() } else { (1..n.saturating_sub(1)).collect() };
|
||||
// Which way the subpath turns as a whole: a convex corner turns the same way.
|
||||
let area: f64 = (0..n).map(|k| pts[k].to_vec2().cross(pts[(k + 1) % n].to_vec2())).sum();
|
||||
n >= 3
|
||||
&& corners.into_iter().any(|k| {
|
||||
let (a, b, c) = (pts[(k + n - 1) % n], pts[k], pts[(k + 1) % n]);
|
||||
let (u, v) = (a - b, c - b);
|
||||
let convex = !sp.closed || (b - a).cross(c - b) * area > 0.0;
|
||||
let angle = (u.dot(v) / (u.hypot() * v.hypot())).clamp(-1.0, 1.0).acos();
|
||||
convex && angle > floor && angle < std::f64::consts::FRAC_PI_4
|
||||
})
|
||||
})
|
||||
}
|
||||
// Views that make slivers whose corners a miter would spike from, in every case.
|
||||
let views = [(-35.0, 25.0), (-85.0, 15.0), (60.0, 60.0)];
|
||||
let mut cases = vec![];
|
||||
for &(x, y) in &views {
|
||||
let view = json!({"rotationX": x, "rotationY": y, "rotationZ": 0, "depth": 60});
|
||||
// Rotate (Classic) takes no depth.
|
||||
let flat = json!({"rotationX": x, "rotationY": y, "rotationZ": 0});
|
||||
let with = |extra: Value| {
|
||||
let mut p = view.clone();
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
p
|
||||
};
|
||||
cases.push((EXTRUDE, x, y, with(json!({"taper": 47.5})), None));
|
||||
// A gradient under a white light: its paint and the shadow fill over it each have a seam.
|
||||
cases.push((EXTRUDE, x, y, with(json!({"taper": 47.5})), Some(gradient.clone())));
|
||||
cases.push((INFLATE, x, y, with(json!({})), None));
|
||||
// Under colored lights: one, two, and a gradient's paint.
|
||||
let one = json!({"taper": 47.5, "lights": [{"color": "#ff8080"}]});
|
||||
let two = json!({"taper": 47.5, "lights": [{"color": "#ff8080"}, {"color": "#8080ff", "rotation": -60}]});
|
||||
cases.push((EXTRUDE, x, y, with(one.clone()), None));
|
||||
cases.push((EXTRUDE, x, y, with(two.clone()), None));
|
||||
cases.push((EXTRUDE, x, y, with(one), Some(gradient.clone())));
|
||||
cases.push((INFLATE, x, y, with(json!({"lights": [{"color": "#ff8080"}, {"color": "#8080ff", "rotation": -60}]})), None));
|
||||
// Rotate turns the art flat: a thin triangle, some corner of which stays sharp enough at
|
||||
// every view; under a white light and a colored one.
|
||||
cases.push((ROTATE, x, y, with(json!({})), None));
|
||||
cases.push((ROTATE, x, y, with(json!({"lights": [{"color": "#ff8080"}]})), None));
|
||||
// Classic: shaded, unshaded (No Shading) and a gradient's paint kept under its shading
|
||||
// layers each make their seam their own way.
|
||||
let bevel = json!({"bevel": "classic", "bevelHeight": 1.5});
|
||||
cases.push((CLASSIC_EXTRUDE, x, y, with(bevel.clone()), None));
|
||||
cases.push((CLASSIC_EXTRUDE, x, y, with(json!({"bevel": "classic", "bevelHeight": 1.5, "surface": "none"})), None));
|
||||
cases.push((CLASSIC_EXTRUDE, x, y, with(bevel), Some(gradient.clone())));
|
||||
// Rotate (Classic) draws its plane through Classic's surfaces: No Shading and Diffuse.
|
||||
for surface in ["none", "diffuse"] {
|
||||
let mut p = flat.clone();
|
||||
p["surface"] = json!(surface);
|
||||
cases.push((CLASSIC_ROTATE, x, y, p, None));
|
||||
}
|
||||
let mut p = flat.clone();
|
||||
p["surface"] = json!("diffuse");
|
||||
cases.push((CLASSIC_ROTATE, x, y, p, Some(gradient.clone())));
|
||||
}
|
||||
// Classic's own paint for its fill: a swatch in CMYK, which its unshaded faces keep as it is.
|
||||
let brick = Paint::Solid { color: Color::Cmyk { c: 0.0, m: 0.75, y: 0.9, k: 0.2 }, swatch: Some("Brick".into()), tint: 1.0 };
|
||||
// Rotate (Classic)'s one plane is shaded at some views, not at every one (the light can leave
|
||||
// it as its paint), so its Diffuse case is held to a darkened face at one view at least.
|
||||
let mut rotate_shaded = false;
|
||||
for (id, x, y, params, paint) in cases {
|
||||
let mut n = if id == ROTATE || id == CLASSIC_ROTATE {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((100.0, 90.0));
|
||||
b.line_to((160.0, 110.0));
|
||||
b.line_to((100.0, 130.0));
|
||||
b.close_path();
|
||||
stroked(&b)
|
||||
} else {
|
||||
stroked_rect(4.0)
|
||||
};
|
||||
let unshaded = params["surface"] == "none";
|
||||
for (k, item) in n.appearance.items.iter_mut().enumerate() {
|
||||
match &paint {
|
||||
Some(paint) => *item.paint_mut() = paint.clone(),
|
||||
None if (id == CLASSIC_EXTRUDE || id == CLASSIC_ROTATE) && k == 0 => *item.paint_mut() = brick.clone(),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
// Each solid on its own (the fill's, then the stroke's), so every check holds for each.
|
||||
for keep in 0..n.appearance.items.len() {
|
||||
let mut solo = n.clone();
|
||||
let item = solo.appearance.items.remove(keep);
|
||||
solo.appearance.items = vec![item.clone()];
|
||||
let source = item.paint().clone();
|
||||
let what = format!("{id} {params} gradient {} view ({x}, {y}) solid {keep}", paint.is_some());
|
||||
solo.appearance.effects.push(fx(id, params.clone()));
|
||||
let art = solid_art(&solo).unwrap();
|
||||
let mut seamed = vec![];
|
||||
art.walk(&mut |face| {
|
||||
if let Some(path) = face.path_data()
|
||||
&& face.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_)))
|
||||
{
|
||||
seamed.push((path.clone(), face.appearance.items.clone()));
|
||||
}
|
||||
});
|
||||
// Each Classic case (Extrude & Bevel and Rotate) reaches its own branch: an unshaded face
|
||||
// keeps the paint itself (its swatch and color space too), a shaded one is the paint
|
||||
// darkened, and a gradient's face keeps the gradient.
|
||||
if id == CLASSIC_EXTRUDE || id == CLASSIC_ROTATE {
|
||||
let fills: Vec<&Paint> = seamed
|
||||
.iter()
|
||||
.filter_map(|(_, items)| match items.first() {
|
||||
Some(AppearanceItem::Fill(f)) => Some(&f.paint),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(!fills.is_empty(), "{what}: faces");
|
||||
if paint.is_some() || unshaded {
|
||||
assert!(fills.iter().all(|f| **f == source), "{what}: every face keeps its paint as it is");
|
||||
} else {
|
||||
let [r, g, b] = source.color().map(|c| c.to_rgb()).unwrap_or([0.0; 3]);
|
||||
let darkened = fills.iter().any(|f| match f {
|
||||
Paint::Solid { color, swatch: None, .. } => {
|
||||
let [fr, fg, fb] = color.to_rgb();
|
||||
fr <= r + 1e-4 && fg <= g + 1e-4 && fb <= b + 1e-4 && fr + fg + fb < r + g + b - 1e-3
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
if id == CLASSIC_ROTATE {
|
||||
rotate_shaded |= darkened;
|
||||
} else {
|
||||
assert!(darkened, "{what}: some face shaded darker than its paint");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every seamed fill (the paint's, a shadow's over it) has its seams checked, and on some
|
||||
// face a miter would spike from.
|
||||
let (mut checked, mut sliver): (usize, Vec<bool>) = (0, vec![]);
|
||||
for (path, items) in &seamed {
|
||||
let outline = path.to_bezpath();
|
||||
let acute_here = items.iter().find_map(|i| match i {
|
||||
AppearanceItem::Stroke(s) => Some(acute(path, s.miter_limit)),
|
||||
_ => None,
|
||||
});
|
||||
let mut which = 0;
|
||||
for item in items {
|
||||
// A seam paints something (a stroke of no width or no paint draws nothing).
|
||||
let AppearanceItem::Stroke(seam) = item else { continue };
|
||||
if seam.width <= 0.0 || matches!(seam.paint, Paint::None) {
|
||||
continue;
|
||||
}
|
||||
if sliver.len() <= which {
|
||||
sliver.resize(which + 1, false);
|
||||
}
|
||||
sliver[which] |= acute_here.unwrap_or(false);
|
||||
which += 1;
|
||||
checked += 1;
|
||||
assert_eq!(seam.join, vectorcraft_doc::LineJoin::Round, "{what}: a seam's joins");
|
||||
// The seam outlined as it is drawn: no point of it farther from the face than
|
||||
// half its width.
|
||||
let drawn = crate::stroke::line_outline(&outline, seam, seam.width, seam.width * 1e-3);
|
||||
let (mut farthest, mut points): (f64, usize) = (0.0, 0);
|
||||
kurbo::flatten(drawn.iter(), seam.width * 1e-4, |el| {
|
||||
if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el {
|
||||
points += 1;
|
||||
farthest = farthest.max(path.nearest(p).map_or(f64::INFINITY, |n| n.4));
|
||||
}
|
||||
});
|
||||
assert!(points > 0, "{what}: a seam outlined as nothing");
|
||||
assert!(farthest <= seam.width / 2.0 * 1.01, "{what}: a seam {} wide reaches {farthest} from its face", seam.width);
|
||||
}
|
||||
}
|
||||
assert!(checked > 0, "{what}: seams checked");
|
||||
for (k, s) in sliver.iter().enumerate() {
|
||||
assert!(s, "{what}: no face with an acute corner carries seam {k}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(rotate_shaded, "Rotate (Classic), Diffuse: no view shades its plane darker than its paint");
|
||||
}
|
||||
|
||||
/// Revolve (Classic) draws its faces through Classic's surfaces: on the plastic, diffuse and
|
||||
/// unshaded surfaces, and with a gradient, every face's seam has round joins and reaches no more
|
||||
/// than half its width past its face as it is outlined (the reach Revolve's overlap test counts on).
|
||||
#[test]
|
||||
fn classic_revolves_seams_are_round() {
|
||||
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
|
||||
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(
|
||||
GradientKind::Linear,
|
||||
vec![GradientStop::new(0.0, RED), GradientStop::new(1.0, BLUE)],
|
||||
))));
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((100.0, 40.0));
|
||||
b.line_to((130.0, 60.0));
|
||||
b.line_to((130.0, 140.0));
|
||||
b.line_to((100.0, 160.0));
|
||||
let cases = [("plastic", Paint::solid(BLUE)), ("diffuse", Paint::solid(BLUE)), ("none", Paint::solid(BLUE)), ("plastic", gradient)];
|
||||
for (surface, paint) in cases {
|
||||
let what = format!("{surface} gradient {}", matches!(paint, Paint::Gradient(_)));
|
||||
let mut n = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(paint, Paint::None, 0.0));
|
||||
n.appearance.effects.push(fx(crate::CLASSIC_REVOLVE, json!({"surface": surface})));
|
||||
let art = crate::revolve_art(&n).unwrap();
|
||||
let mut seams = 0;
|
||||
art.walk(&mut |face| {
|
||||
let Some(path) = face.path_data() else { return };
|
||||
for item in &face.appearance.items {
|
||||
let AppearanceItem::Stroke(s) = item else { continue };
|
||||
if s.width <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
seams += 1;
|
||||
assert_eq!(s.join, vectorcraft_doc::LineJoin::Round, "{what}: a seam's joins");
|
||||
let drawn = crate::stroke::line_outline(&path.to_bezpath(), s, s.width, s.width * 1e-3);
|
||||
let mut farthest: f64 = 0.0;
|
||||
kurbo::flatten(drawn.iter(), s.width * 1e-4, |el| {
|
||||
if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el {
|
||||
farthest = farthest.max(path.nearest(p).map_or(f64::INFINITY, |n| n.4));
|
||||
}
|
||||
});
|
||||
assert!(farthest <= s.width / 2.0 * 1.01, "{what}: a seam {} wide reaches {farthest} from its face", s.width);
|
||||
}
|
||||
});
|
||||
assert!(seams > 0, "{what}: seams checked");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::color::Color;
|
||||
use vectorcraft_doc::{Document, NodeId};
|
||||
use vectorcraft_doc::{Document, NodeId, StrokeLayer};
|
||||
use vectorcraft_geom::{Affine, BezPath};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 3D lighting performance
|
||||
|
||||
Measured 2026-10-17 for PR #2706 on one macOS machine.
|
||||
Measured 2026-10-17 for PR #2706 on one macOS machine (an Apple M1 Pro, below); the round seam joins on 2026-11-12 on another (an Apple M4 Pro, in their own section).
|
||||
|
||||
## Method
|
||||
|
||||
@@ -34,6 +34,8 @@ vectorcraft-cli perf
|
||||
|
||||
## Measurements
|
||||
|
||||
Only the legacy white-light row and the white-light comparison below are affected by the round seam joins of 2026-11-12: they were measured on 2026-10-17, and colored-light seams were already round; see [Round seam joins](#round-seam-joins).
|
||||
|
||||
Milliseconds per frame, medians of three five-frame averages. `8` is the CLI default thread count on this machine; `0` selects the single-thread renderer.
|
||||
|
||||
| Inflate scene | Before, 8 | After, 8 | Before, 0 | After, 0 |
|
||||
@@ -66,10 +68,42 @@ The colored fixture uses the same projected mesh before and after. The solid-fil
|
||||
|
||||
For a smooth colored solid fill, all facets share one compound coverage clip and three isolated channel groups. Each channel has an opaque black backdrop; Screen combines the disjoint RGB channels and Multiply applies the source color. Face order is retained in each channel. Surface opacity and outer coverage apply once. The group/clip counts are independent of triangle count, and export remains vector artwork.
|
||||
|
||||
Source gradients retain their independently placed per-facet vector compositor. They remain a more expensive case. Patterns retain their neutral shading approximation. White-light Inflate uses the original polygon shading, paint composition and default seam joins; the seam width retains its 0.05 to 0.75 pt bounds.
|
||||
Source gradients retain their independently placed per-facet vector compositor. They remain a more expensive case. Patterns retain their neutral shading approximation. White-light Inflate uses the original polygon shading and paint composition; the seam width retains its 0.05 to 0.75 pt bounds. Its seams kept the default Miter Join until 2026-11-12, when every 3D seam took round joins (a mitered seam drew thorns past facets seen almost edge-on), so white-light output is no longer identical to the comparison below.
|
||||
|
||||
Geometry/effects tests cover legacy polygons and seam bounds, shared smooth normals, finite ramps, holes and constant group/clip counts. Engine tests cover the batched SVG round trip, vector PDF, expansion, undo, compound holes and surface opacity.
|
||||
|
||||
## Legacy white-light comparison
|
||||
|
||||
Four original synthetic scenes were exported at 1200 x 1200 from unchanged main `f7de3d6a` and the updated implementation. The PNG files match byte for byte in all four cases: the legacy white-lit ellipse above; the same ellipse with both sides, hollow cap, twist 60 degrees, taper 70%, perspective 80 degrees and view Y rotation 145 degrees; an unshaded ellipse; and the transparent source gradient above under the legacy white light. The command recipe was also checked against the measured colored fixture's image pixels.
|
||||
Four original synthetic scenes were exported at 1200 x 1200 from unchanged main `f7de3d6a` and the updated implementation. The PNG files matched (2026-10-17) byte for byte in all four cases: the legacy white-lit ellipse above; the same ellipse with both sides, hollow cap, twist 60 degrees, taper 70%, perspective 80 degrees and view Y rotation 145 degrees; an unshaded ellipse; and the transparent source gradient above under the legacy white light. The command recipe was also checked against the measured colored fixture's image pixels.
|
||||
|
||||
## Round seam joins
|
||||
|
||||
Measured once, on 2026-11-12, on another machine (an Apple M4 Pro with 14 cores) that was not idle: its one-minute load average was 6 to 13, so these are one loaded run, not the idle measurements above. Two `vectorcraft-cli` builds, each in release (`cargo build --release -p vectorcraft-cli`): main at `6d6bbff53`, and that commit with the round seam joins, run in turn, three times each. The measurement predates the change's rebase onto a main whose Rotate (Classic) and Revolve (Classic) draw their faces through Classic's surfaces, so it does not cover their seams. `bench` fits the artboard to 95% of 2880 x 1800 pixels, draws one frame to warm each renderer and reports the average of the frames after it: `threads 8` with the renderer's default workers (one per core it may use, up to 8, one core left free), `threads 0` on the calling thread alone. A cold frame is a whole `run` that opens the scene, builds its 3D art and draws it once.
|
||||
|
||||
White light: extruded type with a cove bevel, an Inflate, an Extrude & Bevel (Classic) star with a classic bevel and a Revolve, no strokes (6,283 nodes drawn). The median of three runs of 20 frames, on 8 threads and on one: main 8.0 ms and 18.3 ms; with the round joins 7.5 ms and 17.7 ms. Cold frames took 0.08 s of processor time on each. The differences are within noise for one loaded run. Not measured: `vectorcraft-cli perf`, the legacy white-light Inflate row above (still the 2026-10-17 figures), frames whose 3D art is rebuilt (while dragging a 3D slider, say) apart from the cold run, and SVG/PDF exports.
|
||||
|
||||
```
|
||||
{"command":"file.new","params":{"width":800,"height":600}}
|
||||
{"command":"text.create","params":{"x":40,"y":120,"text":"SAMPLE TYPE","size":64,"font":"Source Sans 3","color":"#3366cc"}}
|
||||
{"command":"effect.apply","params":{"effect":"threeD.extrude","item":null,"params":{"depth":40,"rotationX":-35,"rotationY":25,"taper":47.5,"bevel":"cove","bevelHeight":2}}}
|
||||
{"command":"path.create","params":{"d":"M100 300C100 245 145 200 200 200C255 200 300 245 300 300C300 355 255 400 200 400C145 400 100 355 100 300Z"}}
|
||||
{"command":"paint.setFill","params":{"color":"#cc6633"}}
|
||||
{"command":"paint.setStroke","params":{"none":true}}
|
||||
{"command":"effect.apply","params":{"effect":"threeD.inflate","item":null,"params":{}}}
|
||||
{"command":"path.create","params":{"d":"M400 200L440 280L520 290L460 340L480 420L400 380L320 420L340 340L280 290L360 280Z"}}
|
||||
{"command":"paint.setFill","params":{"color":"#33aa66"}}
|
||||
{"command":"paint.setStroke","params":{"none":true}}
|
||||
{"command":"effect.apply","params":{"effect":"threeD.classicExtrude","item":null,"params":{"depth":50,"bevel":"classic","bevelHeight":4,"rotationX":80,"rotationY":10}}}
|
||||
{"command":"path.create","params":{"d":"M600 200L640 200L660 300L640 400L600 400Z"}}
|
||||
{"command":"paint.setFill","params":{"color":"#aa33aa"}}
|
||||
{"command":"paint.setStroke","params":{"none":true}}
|
||||
{"command":"effect.apply","params":{"effect":"threeD.revolve","item":null,"params":{}}}
|
||||
```
|
||||
|
||||
The type is set in Source Sans 3, which a build has only with craft-fonts (`CRAFT_FONTS_DIR`, see `docs/development.md` › Fonts); without it the type falls back to another face. To reproduce, save the commands as `scene.jsonl` and, with each build:
|
||||
|
||||
```
|
||||
vectorcraft-cli run --commands scene.jsonl --export scene.vectorcraft
|
||||
vectorcraft-cli bench scene.vectorcraft --iters 20
|
||||
/usr/bin/time -p vectorcraft-cli run --in scene.vectorcraft --export scene.png
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Effects parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Effect Gallery: a thumbnail for each filter and an in-dialog preview on an original drawing; the 1–2 h for those is done; Extrude & Bevel, Inflate and Rotate merge contours that cross, touch or overlap, as type often draws a glyph, before building a solid; previously: Extrude & Bevel, Inflate and Rotate apply to groups and layers, their objects turning about one shared axis (#4506); the Transform effect's copies of a path with a fill and a stroke (or several fills and strokes, an even-odd fill, or a partly transparent one) are drawn each above the one before, as Expand Appearance makes them (#5920); Expand Appearance makes each copy of a path's Transform effect an object of its own (#5612); previously: Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Extrude & Bevel, Inflate and Rotate seams have round joins under a white light and in the 3D (Classic) effects too, no longer reaching past the solid as thorns; previously: Effect Gallery: a thumbnail for each filter and an in-dialog preview on an original drawing; the 1–2 h for those is done; Extrude & Bevel, Inflate and Rotate merge contours that cross, touch or overlap, as type often draws a glyph, before building a solid; previously: Extrude & Bevel, Inflate and Rotate apply to groups and layers, their objects turning about one shared axis (#4506); the Transform effect's copies of a path with a fill and a stroke (or several fills and strokes, an even-odd fill, or a partly transparent one) are drawn each above the one before, as Expand Appearance makes them (#5920); Expand Appearance makes each copy of a path's Transform effect an object of its own (#5612); previously: Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue
|
||||
(`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it
|
||||
@@ -179,9 +179,9 @@ VectorCraft choices; an original approximation, never compared with another appl
|
||||
| Item | Ours |
|
||||
|---|---|
|
||||
| Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) |
|
||||
| Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) |
|
||||
| Extrude & Bevel, Inflate, Rotate | initial: live, round seam joins, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) |
|
||||
| Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect, and Quick Actions › Expand as Wireframes ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); the Materials tab's Graphics, document symbols mapped onto Extrude & Bevel's caps and onto a plane, moved with an artboard handle, and wrapped around Revolve's side ([three-d.md](three-d.md#graphics)); no base materials, graphics on Inflate or on Extrude & Bevel's walls and bevels, soft shadows or ray tracing |
|
||||
| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Map Art: none |
|
||||
| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, round seam joins, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, round seam joins, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, round seam joins, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Map Art: none |
|
||||
| Turntable (30.0 beta) | none |
|
||||
| Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object collects an object with Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, and Export writes its solids as a USDA (the default), USDZ, glTF or OBJ mesh with a material in each solid's paint color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`); no textures, map art or shading ([3D export help](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)) |
|
||||
|
||||
@@ -193,6 +193,7 @@ angular cut caps, exact visibility, the rest of 3D Classic (Map Art), Turntable)
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-12 | minor | The seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins (the seams of Revolve and of colored lights, and 3D (Classic) Wireframe edges, were already round): no thorns on edge-on facets, and facets with no neighbor now have rounded convex corners ([three-d.md](three-d.md#seams)); scores and estimates unchanged |
|
||||
| 2026-11-10 | minor | Effect Gallery: each filter has a thumbnail, and the dialog has a preview of the visible stack (or the browsed filter when the stack is empty). Both are an original landscape drawing passed through the filter ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)). The canvas preview and one-undo OK are unchanged. Remaining hours for the gallery's thumbnails and in-dialog preview: 1–2 → none. Counts unchanged (57/57). |
|
||||
| 2026-11-08 | minor | Extrude & Bevel, Inflate and Rotate merge contours that cross, touch or overlap (a glyph's arm across its stem) before building a solid, so the solid follows the shape as it is filled; others are built as drawn ([three-d.md](three-d.md)); scores and estimates unchanged |
|
||||
| 2026-11-06 | minor | Extrude & Bevel, Inflate and Rotate apply to a group or layer: a solid for every path, compound path and type in it, all turned about one shared axis as the public documentation describes for groups ([three-d.md](three-d.md#groups), #4506); scores and estimates unchanged |
|
||||
|
||||
+3
-2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+30
-3
@@ -86,7 +86,7 @@ object is where the art was.
|
||||
| Bevel both sides | The back is bevelled too. |
|
||||
|
||||
Holes stay holes, with walls of their own. Bevels move each corner along its bisector (mitres are limited to four
|
||||
times the width), so a bevel wider than a narrow part of the shape folds over itself.
|
||||
times the width), so a bevel wider than a narrow part of the shape folds over itself. Each facet has a seam ([Seams](#seams)).
|
||||
|
||||
## Inflate
|
||||
|
||||
@@ -102,7 +102,33 @@ middle.
|
||||
| Volume | 0–100 %. The surface is a membrane under even pressure, held along the outline (and around holes): it rises vertically from the edge and rounds smoothly inside, without creases along the shape's middle. At 50 % a circle becomes a half sphere; 100 % puffs twice as high, 0 % is flat. |
|
||||
| Both sides | The back puffs out too; otherwise it is flat. |
|
||||
|
||||
The surface starts with a grid of 48 cells across the larger side, halved or quartered where it rises steeply near the outline. White-light Inflate retains the existing clipped polygon cells, shading, paint composition and seam joins. With colored lights, simple cells are triangulated with shared border samples, and lighting interpolates shared membrane normals through ordinary RGB vector gradients, including on a puffed back. Solid fills batch those triangles into three channel groups and one coverage clip for the whole surface. Source gradients retain per-facet vector composition and apply transparent coverage once. Seam widths retain the 0.05 to 0.75 pt bounds; colored facets use round joins. Shading is automatic, with no additional dialog option.
|
||||
The surface starts with a grid of 48 cells across the larger side, halved or quartered where it rises steeply near the outline. White-light Inflate retains the existing clipped polygon cells, shading and paint composition. With colored lights, simple cells are triangulated with shared border samples, and lighting interpolates shared membrane normals through ordinary RGB vector gradients, including on a puffed back. Solid fills batch those triangles into three channel groups and one coverage clip for the whole surface. Source gradients retain per-facet vector composition and apply transparent coverage once. Seam widths retain the 0.05 to 0.75 pt bounds, and every facet's seam has round joins ([Seams](#seams)). Shading is automatic, with no additional dialog option.
|
||||
|
||||
## Seams
|
||||
|
||||
Facets side by side, each antialiased on its own, would leave hairlines between them, so 3D facets are drawn with
|
||||
a seam of their own paint, with round joins. In Extrude & Bevel, Inflate, Rotate and the three 3D (Classic) effects a
|
||||
facet's seam is half as wide as the facet is thick, from 0.05 to 0.75 pt; Revolve's seam is always 0.75 pt.
|
||||
|
||||
How a facet carries its seam depends on the lights and the paint:
|
||||
|
||||
- Extrude & Bevel, Inflate and Rotate, when no light is colored (one white light or several): a path filled with
|
||||
the facet's shading and stroked with its seam. With a colored light, Extrude & Bevel and Rotate (planar shading)
|
||||
are drawn the same way.
|
||||
- Inflate with a colored light and shading on: a solid-color fill draws each facet's light ramps with its seam in three channel
|
||||
groups clipped to the union of its facets, so no seam reaches past the surface; a gradient fill fills each facet's
|
||||
ramps through a clip of the facet and its seam, outlined with round joins.
|
||||
- Extrude & Bevel (Classic), Rotate (Classic) and Revolve (Classic): a path and a seam for every face; gradient and
|
||||
pattern faces seam only the base paint, and the shading and highlight layers cover the face alone. The Wireframe
|
||||
surface draws edges only, with no seam.
|
||||
- Revolve, with shading on: when a `lights` list is given (even one of white lights), a solid-color or gradient face fills its light
|
||||
ramps through a clip of the face and its 0.75 pt seam, outlined with round joins. With the single legacy light,
|
||||
a face is a path and a seam; with a colored `lightColor` a gradient face fills through the same kind of clip.
|
||||
|
||||
A facet seen almost edge-on is a sliver, and a seam with mitered joins would reach past the solid as a thorn; a round
|
||||
one reaches no more than half its width past its facet. So where a facet has no neighbor (Rotate's plane, every
|
||||
silhouette corner) its convex corners are drawn rounded by up to 0.375 pt (half the largest seam), except a
|
||||
solid-color Inflate under a colored light, whose clip keeps its corners sharp.
|
||||
|
||||
## Fills and strokes
|
||||
|
||||
@@ -206,7 +232,7 @@ solids are ([Groups](#groups)), turned about one shared axis.
|
||||
|
||||
The flat art turned in 3D about the centre of its bounds. Every fill and stroke is turned with its own paint (a
|
||||
stroke as its outline, so it foreshortens with the art) and shaded as one plane; a plane has two sides, so its
|
||||
back shows when it is turned past 90°.
|
||||
back shows when it is turned past 90°. The plane has a seam ([Seams](#seams)).
|
||||
|
||||
## Rotation presets
|
||||
|
||||
@@ -465,6 +491,7 @@ Release measurements and a repeatable CLI scene are in [3D lighting performance]
|
||||
|
||||
| Date | Change |
|
||||
|---|---|
|
||||
| 2026-11-12 | The seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins (the seams of Revolve and of colored lights, and 3D (Classic) Wireframe edges, were already round): no thorns on edge-on facets, and facets with no neighbor now have rounded convex corners ([Seams](#seams)). |
|
||||
| 2026-11-08 | Contours that cross, touch or overlap are merged by vectorcraft-pathops before Extrude & Bevel, Inflate and Rotate build a solid, sharing its limits; others are built as drawn. |
|
||||
| 2026-11-06 | Extrude & Bevel, Inflate and Rotate apply to groups and layers: every path, compound path and type in them turns about one shared axis (#4506). |
|
||||
| 2026-10-21 | Four original lighting presets, shared by all four 3D effects and commands, with effective-setting recognition and existing preview/history behavior. |
|
||||
|
||||
Reference in New Issue
Block a user