3D: a stroke's walls cover the fill's walls at concave corners

Extrude & Bevel (both kinds) and Inflate drew the faces of the fill's
solid and the stroke's solid in one order by each face's center depth.
At a concave corner a long fill wall could sort after a stroke wall in
front of it, so stripes of the fill showed through the stroke.

- Facets now record whether they are side faces (walls, bevels,
  Inflate's sides) and whether they are turned away from the viewer.
- With caps on, each fill side face turned toward the viewer is drawn
  before every face of the same shape's stroke whose bounds overlap
  it, for strokes whose solid hides the fill's whole edge: a closed
  shape, a width, no dashes, width profile or brush, and a solid color
  or a gradient without transparent stops. Caps, other shapes in a
  group, hollow effects and translucent or blended surfaces keep their
  order.

Docs: docs/three-d.md (Fills and strokes), docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-11 15:32:33 -08:00
parent 7f3a046b18
commit 5e78aa6575
7 changed files with 319 additions and 23 deletions
+141 -22
View File
@@ -516,8 +516,8 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData,
}
/// 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]);
/// fill rule, its shape's scopes and the index of its shape.
type Part<'a> = (&'a AppearanceItem, f32, PathData, FillRule, &'a [Scope], usize);
/// The parts Extrude & Bevel and Inflate build solids from: each shape's top fill and its strokes,
/// a group's members from the back up, each with its shape's opacity, the region it paints and its
@@ -526,18 +526,128 @@ type Part<'a> = (&'a AppearanceItem, f32, PathData, FillRule, &'a [Scope]);
fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec<Part<'_>>, Rect) {
let parts: Vec<Part> = shapes
.iter()
.flat_map(|s| {
.enumerate()
.flat_map(|(member, s)| {
let top_fill = s.items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_)));
s.items.iter().enumerate().filter(move |(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_))).map(|(_, i)| {
s.items.iter().enumerate().filter(move |(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_))).map(move |(_, i)| {
let (area, rule) = region(&s.path, s.rule, i);
(i, s.opacity, area, rule, s.scopes.as_slice())
(i, s.opacity, area, rule, s.scopes.as_slice(), member)
})
})
.collect();
let bounds = parts.iter().filter_map(|(_, _, area, _, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(fallback);
let bounds = parts.iter().filter_map(|(_, _, area, _, _, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(fallback);
(parts, bounds)
}
/// What a face of one of the solids [`art_for`] draws in one order was built from: the facet's
/// depth, whether it is a side face and whether it is turned away from the viewer, the bounds of
/// its polygon, the index of its shape, and whether its solid is that shape's fill or one of its
/// strokes that covers the fill's whole edge.
struct Placed {
depth: f64,
side: bool,
away: bool,
bounds: Rect,
member: usize,
paints: Paints,
}
/// The part of its shape a face's solid is built from ([`Placed`]).
#[derive(Clone, Copy, PartialEq, Eq)]
enum Paints {
Fill,
/// A stroke whose solid holds the fill's whole edge and hides it: one with a width, without
/// dashes, a width profile or a brush, painted with a solid color or a gradient without
/// transparent stops, on a shape whose subpaths are all closed.
Stroke,
Other,
}
impl Paints {
/// What `item` of a shape is, the shape's subpaths all closed when `closed`.
fn of(item: &AppearanceItem, closed: bool) -> Self {
match item {
AppearanceItem::Fill(_) => Paints::Fill,
AppearanceItem::Stroke(s)
if closed
&& s.width > 0.0
&& s.width.is_finite()
&& s.dash.is_none()
&& s.profile.is_none()
&& s.brush.is_none()
&& match &s.paint {
Paint::Solid { .. } => true,
Paint::Gradient(_) => crate::lighting::opaque_paint(&s.paint).1.is_none(),
_ => false,
} =>
{
Paints::Stroke
}
AppearanceItem::Stroke(_) => Paints::Other,
}
}
}
impl Placed {
/// The face of `f`, of shape `member`'s part `paints`.
fn of(f: &Facet, member: usize, paints: Paints) -> Self {
let mut points = f.contours.iter().flatten();
let bounds = points.next().map_or(Rect::ZERO, |p| points.fold(Rect::from_points(*p, *p), |r, q| r.union_pt(*q)));
Placed { depth: f.depth, side: f.side, away: f.away, bounds, member, paints }
}
}
/// The most bounds comparisons [`draw_order`] makes; the fill walls it has not reached by then keep
/// their depths.
const MAX_OVERLAP_CHECKS: usize = 4_000_000;
/// The order faces `faces` are drawn in, back to front, as indices into `faces`: by depth (faces
/// at the same depth in the order given), except that with `sealed` (solids with caps) a side face
/// of a shape's fill turned toward the viewer is drawn before each face of the same shape's
/// covering strokes ([`Paints::Stroke`]), turned toward the viewer, whose bounds overlap its own.
/// Such a stroke's closed solid holds the fill's walls, so they never show and can be drawn under
/// any other face.
fn draw_order(faces: &[Placed], sealed: bool) -> Vec<usize> {
// Each face's depth to sort by, and whether it was moved before a stroke face at that depth.
let mut keys: Vec<(f64, bool)> = faces.iter().map(|f| (f.depth, false)).collect();
if sealed {
// The stroke faces turned toward the viewer of each shape, nearest last.
let mut strokes: Vec<Vec<&Placed>> = vec![];
for f in faces.iter().filter(|f| f.paints == Paints::Stroke && !f.away) {
if strokes.len() <= f.member {
strokes.resize_with(f.member + 1, Vec::new);
}
strokes[f.member].push(f);
}
for list in &mut strokes {
list.sort_by(|a, b| a.depth.total_cmp(&b.depth));
}
let overlap = |a: &Rect, b: &Rect| a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
let mut checks = 0usize;
for (f, key) in faces.iter().zip(&mut keys) {
if f.paints != Paints::Fill || !f.side || f.away {
continue;
}
let Some(list) = strokes.get(f.member).filter(|l| !l.is_empty()) else { continue };
for s in list.iter().take_while(|s| s.depth <= f.depth) {
if checks >= MAX_OVERLAP_CHECKS {
break;
}
checks += 1;
if overlap(&s.bounds, &f.bounds) {
*key = (s.depth, true);
break;
}
}
}
}
let mut order: Vec<usize> = (0..faces.len()).collect();
// A stable sort: faces at the same depth keep the order of the appearance, and a fill's side
// face moved to a stroke face's depth goes before it.
order.sort_by(|&a, &b| keys[a].0.total_cmp(&keys[b].0).then(keys[b].1.cmp(&keys[a].1)));
order
}
/// `shapes`, the members of group or layer `n`, reshaped by the visible geometry effects `before`
/// its 3D effect, about the bounds of all of them.
pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) {
@@ -556,7 +666,9 @@ pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile
/// 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);
/// depths tie, as for the caps turned toward the viewer, in the order of the appearance, and with
/// caps, a shape's fill walls before the faces of its covering strokes that overlap them:
/// [`draw_order`]);
/// otherwise each solid is a surface of its own, in that order. A member's opacity mask and
/// blending mode, and those of a group it is in, apply to the surfaces of the members it holds
/// ([`masked`]).
@@ -588,17 +700,17 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>>
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();
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()
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) {
for ((item, opacity, _, _, scopes, member), 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)),
Ok(f) => solids.push((item, *opacity, *scopes, f, *member)),
// 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
@@ -610,14 +722,16 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>>
if solids.is_empty() {
return None;
}
let opaque = solids.iter().all(|(item, member, _, _)| {
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
let surfaces: Vec<(Vec<Placed>, Node, &[Scope])> = solids
.into_iter()
.map(|(item, member, scopes, f)| {
let depths: Vec<f64> = f.iter().map(|f| f.depth).collect();
.map(|(item, member, scopes, f, shape)| {
let closed = shapes.get(shape).is_some_and(|s| s.path.subpaths.iter().all(|p| p.closed));
let paints = Paints::of(item, closed);
let depths: Vec<Placed> = f.iter().map(|f| Placed::of(f, shape, paints)).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)
@@ -635,15 +749,20 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>>
if !opaque || !plain {
return Some(surfaces.into_iter().map(|(_, s, _)| Arc::new(s)).collect());
}
let mut faces = vec![];
let (mut placed, mut faces) = (vec![], vec![]);
for (depths, s, _) in surfaces {
if let NodeKind::Group { children, .. } = s.kind {
faces.extend(depths.into_iter().zip(children));
placed.extend(depths);
faces.extend(children.into_iter().map(Some));
}
}
// 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()))])
let sealed = match fx.id.as_str() {
INFLATE => inflate_options(&p).cap,
CLASSIC_EXTRUDE => classic_extrude_options(&p).cap,
_ => extrude_options(&p).cap,
};
let order = draw_order(&placed, sealed);
Some(vec![Arc::new(Node::group(n.id, order.into_iter().filter_map(|k| faces.get_mut(k).and_then(Option::take)).collect()))])
}
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
@@ -788,11 +907,11 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize)
// 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();
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 {
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())),
+131
View File
@@ -180,6 +180,137 @@ fn the_front_caps_are_drawn_last_in_the_order_of_the_appearance() {
}
}
/// A C, open to the right: two concave corners where its walls meet the walls of the notch.
fn letter_c() -> BezPath {
let mut b = BezPath::new();
b.move_to((100.0, 100.0));
b.line_to((200.0, 100.0));
b.line_to((200.0, 130.0));
b.line_to((130.0, 130.0));
b.line_to((130.0, 170.0));
b.line_to((200.0, 170.0));
b.line_to((200.0, 200.0));
b.line_to((100.0, 200.0));
b.close_path();
b
}
/// The pairs (blue, red) of `walls`, by index, where the blue face is drawn first and the two
/// faces' bounds overlap.
fn red_after_blue(walls: &[&Node]) -> Vec<(usize, usize)> {
let bounds: Vec<Rect> = walls.iter().map(|f| f.path_data().and_then(PathData::bounds).unwrap()).collect();
let overlap = |a: &Rect, b: &Rect| a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
let mut out = vec![];
for (i, a) in walls.iter().enumerate() {
for (j, b) in walls.iter().enumerate().skip(i + 1) {
if blue(a) && !blue(b) && overlap(&bounds[i], &bounds[j]) {
out.push((i, j));
}
}
}
out
}
/// An opaque stroke's solid holds the fill's edge, so the fill's walls are drawn before every
/// face of the stroke they overlap, and the stroke's walls cover them at the concave corners of a
/// turned C, in Extrude & Bevel and Extrude & Bevel (Classic). The front caps stay last, the
/// fill's below the stroke's.
#[test]
fn a_fills_walls_are_drawn_before_the_stroke_faces_over_them() {
let views = [json!({}), json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0}), json!({"rotationX": 25, "rotationY": -55, "rotationZ": 10})];
for id in [EXTRUDE, CLASSIC_EXTRUDE] {
for view in &views {
let mut params = view.clone();
params["depth"] = json!(60);
let mut n = stroked(&letter_c());
n.appearance.effects.push(fx(id, params));
let art = solid_art(&n).unwrap();
let faces = faces(&art);
let [walls @ .., fill_cap, stroke_cap] = faces.as_slice() else { panic!("{id} {view}: the caps") };
assert!(!blue(fill_cap) && blue(stroke_cap), "{id} {view}: the caps last");
assert!(walls.iter().any(|f| !blue(f)) && walls.iter().any(|f| blue(f)), "{id} {view}: walls of both");
let wrong = red_after_blue(walls);
assert!(wrong.is_empty(), "{id} {view}: fill walls after the stroke faces over them: {wrong:?} of {}", walls.len());
}
}
// Strokes aligned inside or outside keep the fill's walls, in that order. A dashed stroke
// leaves the fill's walls in the gaps between its dashes, and they keep their depths.
use vectorcraft_doc::StrokeAlign;
for (dash, align) in [(true, StrokeAlign::Center), (false, StrokeAlign::Inside), (false, StrokeAlign::Outside)] {
let mut n = stroked(&letter_c());
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
s.dash = dash.then(|| vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false });
s.align = align;
}
n.appearance.effects.push(fx(EXTRUDE, views[1].clone()));
let art = solid_art(&n).unwrap();
let faces = faces(&art);
let [walls @ .., _, _] = faces.as_slice() else { panic!("{align:?}: the caps") };
assert!(walls.iter().any(|f| !blue(f)), "{align:?}: the fill's walls");
if !dash {
assert!(red_after_blue(walls).is_empty(), "{align:?}");
}
}
}
/// The order keeps depths except where a shape's fill wall turned toward the viewer overlaps a
/// face of the same shape's stroke drawn before it: the wall then goes just before that face. A
/// cap, a face turned away, another shape's stroke, a stroke face the wall does not overlap and an
/// effect without caps keep the order of the depths.
#[test]
fn the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps() {
let at = |depth: f64, x: f64, member: usize, paints: Paints, side: bool, away: bool| Placed {
depth,
side,
away,
bounds: Rect::new(x, 0.0, x + 10.0, 10.0),
member,
paints,
};
// 0 the fill's wall; 1 the stroke's wall over it; 2 another shape's stroke over it; 3 a stroke
// wall elsewhere; 4 a stroke face turned away; 5 a fill cap; 6 the stroke's cap, nearer; 7 a
// fill wall at the stroke wall's depth.
let faces = [
at(5.0, 0.0, 0, Paints::Fill, true, false),
at(3.0, 5.0, 0, Paints::Stroke, true, false),
at(2.0, 5.0, 1, Paints::Stroke, true, false),
at(1.0, 50.0, 0, Paints::Stroke, true, false),
at(0.0, 0.0, 0, Paints::Stroke, true, true),
at(4.0, 0.0, 0, Paints::Fill, false, false),
at(6.0, 0.0, 0, Paints::Stroke, false, false),
at(3.0, 0.0, 0, Paints::Fill, true, false),
];
assert_eq!(draw_order(&faces, true), [4, 3, 2, 0, 7, 1, 5, 6]);
assert_eq!(draw_order(&faces, false), [4, 3, 2, 1, 7, 5, 0, 6]);
// A shape with no stroke, and a stroke without a width, move nothing.
// Only a stroke whose solid hides the fill's whole edge counts: an opaque, plain stroke with a
// width on a closed shape.
let stroke = |f: &dyn Fn(&mut StrokeLayer)| {
let mut s = StrokeLayer::new(Paint::solid(BLUE), 6.0);
f(&mut s);
AppearanceItem::Stroke(s)
};
assert!(Paints::of(&stroke(&|_| {}), true) == Paints::Stroke);
assert!(Paints::of(&stroke(&|_| {}), false) == Paints::Other, "an open shape");
assert!(Paints::of(&stroke(&|s| s.width = 0.0), true) == Paints::Other, "no width");
let dash = vectorcraft_doc::Dash { pattern: vec![4.0, 4.0], offset: 0.0, align_corners: false };
assert!(Paints::of(&stroke(&|s| s.dash = Some(dash.clone())), true) == Paints::Other, "dashes");
assert!(Paints::of(&stroke(&|s| s.brush = Some("brush".into())), true) == Paints::Other);
assert!(Paints::of(&stroke(&|s| s.paint = Paint::None), true) == Paints::Other);
assert!(Paints::of(&stroke(&|s| s.paint = Paint::Pattern { pattern: "p".into(), xf: Affine::IDENTITY }), true) == Paints::Other, "a pattern");
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
let gradient = |opacity: f32| {
let mut end = GradientStop::new(1.0, RED);
end.opacity = opacity;
Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, BLUE), end]))))
};
assert!(Paints::of(&stroke(&|s| s.paint = gradient(1.0)), true) == Paints::Stroke, "an opaque gradient");
assert!(Paints::of(&stroke(&|s| s.paint = gradient(0.5)), true) == Paints::Other, "a gradient with a transparent stop");
let other = |p: Paints| if p == Paints::Stroke { Paints::Other } else { p };
let unstroked: Vec<Placed> = faces.iter().map(|f| at(f.depth, f.bounds.x0, f.member, other(f.paints), f.side, f.away)).collect();
assert_eq!(draw_order(&unstroked, true), draw_order(&faces, false));
}
/// A stroked open path builds a ribbon, the stroke's outline, not the area the path would enclose
/// if it were closed: for an L of two 100 pt legs, a 6 pt band with butt ends and a mitered corner.
#[test]
+6
View File
@@ -257,6 +257,12 @@ pub struct Facet {
/// The facet's outline with the profile's curves kept as cubics, for [`Facet::path`]. The
/// contours stay the polygon that visibility and depth use.
pub outline: Option<PathData>,
/// The facet is a piece of the solid's sides (Extrude & Bevel's walls and bevels, Inflate's
/// sides) rather than of its ends (the caps, a plane, Inflate's front and back).
pub side: bool,
/// The facet is turned away from the viewer: a closed solid keeps it (seen edge-on, or with
/// Draw Hidden Faces) and draws it behind the facets turned toward the viewer.
pub away: bool,
/// The contours in view space, before projection, for tests that measure depths.
#[cfg(test)]
view: Vec<Vec<V3>>,
+7
View File
@@ -142,6 +142,8 @@ impl Builder<'_> {
normal: [n.0, n.1, n.2],
rgb_gradient: None,
outline: None,
side: false,
away: behind,
#[cfg(test)]
view,
});
@@ -221,6 +223,11 @@ impl Builder<'_> {
}
let before = self.facets.len();
self.face(vec![quad], normal, order)?;
if self.facets.len() > before
&& let Some(f) = self.facets.last_mut()
{
f.side = true;
}
if let Some(w) = curves.as_mut().filter(|_| pieces == 1)
&& let Some(span) = w.spans.get(i).copied().flatten()
{
+27
View File
@@ -98,6 +98,33 @@ fn hidden_faces_draw_every_face_of_the_solid_behind_the_visible_ones() {
}
}
/// Each facet says whether it is a piece of the solid's sides and whether it is turned away from
/// the viewer: a box's walls and bevels are sides and its two caps are not; Inflate's sides are
/// sides and its front is not.
#[test]
fn facets_tell_the_sides_from_the_ends_and_the_faces_turned_away() {
let (p, b) = shape(&square(0.0, 0.0, 100.0));
let v = View { rotation_x: 20.0, rotation_y: 30.0, ..front() };
let bevel = Bevel { width: 10.0, height: 10.0, ..Bevel::default() };
for o in [Extrude { hidden: true, ..Extrude::default() }, Extrude { hidden: true, bevel: Some(bevel), ..Extrude::default() }] {
let all = extrude(&p, FillRule::EvenOdd, b, o, v).unwrap();
let ends: Vec<&Facet> = all.iter().filter(|f| !f.side).collect();
let [a, c] = ends[..] else { panic!("two caps among {} facets", all.len()) };
let along = a.normal[0] * c.normal[0] + a.normal[1] * c.normal[1] + a.normal[2] * c.normal[2];
assert!(close(along, -1.0, 1e-9), "the caps face opposite ways: {along}");
assert!(all.len() > 6 || o.bevel.is_none(), "the bevel adds sides");
// The faces turned away are drawn first, the others after them.
let away = all.iter().take_while(|f| f.away).count();
assert!(away > 0 && all[away..].iter().all(|f| !f.away), "{away} of {}", all.len());
}
let shown = extrude(&p, FillRule::EvenOdd, b, Extrude::default(), v).unwrap();
assert!(shown.iter().all(|f| !f.away));
let puff = inflate(&p, FillRule::EvenOdd, b, Inflate { depth: 20.0, ..Inflate::default() }, v).unwrap();
assert!(puff.iter().any(|f| f.side) && puff.iter().any(|f| !f.side));
let flat = inflate(&p, FillRule::EvenOdd, b, Inflate::default(), v).unwrap();
assert!(!flat.is_empty() && flat.iter().all(|f| !f.side));
}
#[test]
fn taper_shrinks_the_back_and_twist_turns_it() {
let (p, b) = shape(&square(0.0, 0.0, 100.0));
+1
View File
@@ -1087,6 +1087,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Divide uses open lines, bent paths and curves as cutting edges (#1307), preserving the frontmost filled face's appearance and holes. Engine tests cover implicit closure of filled open contours and unrelated objects; native automation confirms independent piece movement, one Undo/Redo, canvas/window captures and an exact saved-document read by a separate CLI process. The shared planar map keeps a segment cap (65,536 since #937, was 4,096) and its filled-area fallback; other Pathfinder operations are unchanged.
- Type that Transform effects only move and copy (no scale, rotation, reflection or Random) exports as type in each place, and PDF export writes every copy as selectable, searchable text, when its characters are in solid colors without underline or strikethrough, the type object has no fills or strokes of its own, no inline graphics and no perspective, its transform does not scale or shear stroked characters, and the copies do not overlap. When the effect is on the type itself, the type can also have transparency, an opacity mask and raster effects. When the effect is on the group or layer holding the type, the type has no transparency or effects of its own and is not in a clipping path, the group or layer has no fill, stroke or Pathfinder effect of its own, and no group between them has a fill, stroke, Pathfinder or geometry effect. Type still exports as outlines when the effect stops its copies early, or when the copies would number more than 10,000 or take more than 20,000,000 bytes of document data, and so does other type under a geometry effect.
- Extrude & Bevel and Inflate treat strokes as outlined: each visible stroke builds a solid from its outline in its own paint beside the top fill's ([three-d.md](three-d.md#fills-and-strokes)).
- With caps and opaque paint, Extrude & Bevel (both kinds) and Inflate draw each side face of the fill's solid before the faces of the same object's strokes that overlap it (strokes on closed paths without dashes, a width profile or a brush, in a solid color or an opaque gradient), so the stroke's walls cover the fill's walls at concave corners instead of showing stripes of the fill. Tests: `solids::tests::a_fills_walls_are_drawn_before_the_stroke_faces_over_them`, `solids::tests::the_draw_order_moves_a_fill_wall_before_the_stroke_faces_it_overlaps`, `solid::tests::facets_tell_the_sides_from_the_ends_and_the_faces_turned_away`.
- Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715).
- Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)).
- Extrude & Bevel and Inflate build detailed shapes: each solid takes up to 65,536 outline points and 163,840 faces (VectorCraft's own limits, was 2,048 points and 32,768 faces), counted before any point is sampled; when the faces do not fit, every curve takes fewer pieces, down to 4. A closed path of 2,000 curves extrudes with a bevel and inflates, and paths of 5,000 and 10,000 curves build at the default settings (`crates/three-d/src/solid/tests_limits.rs`).
+6 -1
View File
@@ -99,7 +99,12 @@ reply on the reference app's bug forum says that strokes "will be treated as out
The rest is our reading:
- When the fill and every stroke are opaque with Normal blending, the faces of all the solids are drawn in one
back-to-front order, and the caps turned toward the viewer are drawn last, the lower item's first. Otherwise
back-to-front order, and the caps turned toward the viewer are drawn last, the lower item's first. With caps on,
each side face of the fill's solid that is turned toward the viewer is drawn before every face of the same
shape's strokes, turned toward the viewer, whose bounds overlap it, so the stroke's walls cover the fill's walls
at concave corners. This applies to strokes on closed paths without dashes, a width profile or a brush, painted
with a solid color or a gradient without transparent stops; with other strokes, and without caps, the faces keep
their depth order. Otherwise
each solid is drawn on its own with its opacity and blending, the lower item's first. With a light that is not
white, each solid is also drawn on its own when one of them is a puffed, shaded Inflate surface in a solid color
(drawn as one composite) or is painted with a gradient that has transparent stops (drawn with an opacity mask).