mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-08 20:01:37 +00:00
3D effects: resolve a profile only when its contours meet
The boolean that merges crossing contours cost about as much as the rest of a plain extrusion, and it ran on every profile. Most art has no contours that meet, so the profile is now sampled first (its point limit checked there, before any boolean) and resolved only when two edges cross, touch, run along each other or fold back, in one contour or across two. Edges are swept by their left ends, within a pair budget past which the boolean decides. Touching counts: squares sharing an edge inflate as one puff, not two with a seam. On a release build, Extrude of 120 separate contours takes 0.31 ms instead of 4.18 ms; profiles that do cross cost what they did. Tests: the meeting cases, squares sharing an edge, the fallback for a profile the boolean refuses, Inflate's extent and area for the crossing k, and the coincident circles' area. The hairline test now holds a crossing pair so that its profile still goes through the boolean. Docs: the merge is described once for all three effects, linking to a new Limits section in the pathops README instead of repeating its limits, and the point limit notes that a merged outline can have more points.
This commit is contained in:
@@ -87,6 +87,21 @@ pub fn split_into_grid(rect: Rect, rows: usize, cols: usize, gutter: f64) -> Vec
|
||||
|
||||
`simplify` fits least-squares cubics with fixed end tangents, then reparameterizes with Newton steps and splits at the point of worst error. Any anchor that turns more than the corner threshold (30° by default) stays a corner.
|
||||
|
||||
## Limits
|
||||
|
||||
Every boolean, Pathfinder operation, region map and normalization goes through one sweep, and refuses input it can't
|
||||
sweep with a [`PathOpsError`](src/lib.rs) rather than answering with part of the art missing:
|
||||
|
||||
- `NonFinite`: a coordinate is NaN or infinite.
|
||||
- `Degenerate`: the coordinates are so large that the sweep's tolerance would pass one unit (`MAX_SWEEP_EPS`), so
|
||||
features thinner than it would collapse.
|
||||
- `TooComplex`: more than [`MAX_SEGMENT_PAIRS`](src/budget.rs) pairs of segments have overlapping bounds. They
|
||||
are counted exactly before the sweep, in O(n log n). This bounds what the sweep is given, not how many times the
|
||||
segments cross: a compact path that crosses itself many times can stay under it and still take seconds.
|
||||
|
||||
Results are refitted within the precision (`DEFAULT_PRECISION` unless a `_with` variant is given) and contours
|
||||
thinner than it on average are dropped as slivers (see Booleans above).
|
||||
|
||||
## Caveats
|
||||
|
||||
- linesweeper is still in beta. When two identical curves overlap, the sweep can leave a tiny lens between them; the sliver filter removes it.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::rings::{MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, inset, left, resolved, rings, signed_area};
|
||||
use super::rings::{MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, inset, left, signed_area, solid_rings};
|
||||
use super::{Bevel, Extrude, Facet, FillRule, MAX_FACES, PathData, Point, Rect, Revolve, V3, View, eye_distance, perspective_scale};
|
||||
|
||||
/// Turns 3D points into document points and shades faces for one view.
|
||||
@@ -403,8 +403,7 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio
|
||||
if o.depth <= 0.0 {
|
||||
return plane_faces(path, rule, bounds, view, mesh);
|
||||
}
|
||||
let (path, rule) = resolved(path, rule);
|
||||
let rings = rings(&path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
|
||||
let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
|
||||
let camera = Camera::new(view, bounds, o.depth);
|
||||
let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![], mesh: mesh.then(Vec::new) };
|
||||
let steps = steps(&o);
|
||||
@@ -462,8 +461,7 @@ pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Resul
|
||||
/// [`plane`], or with `mesh` its face before projection instead of its facet.
|
||||
pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, mesh: bool) -> Result<Built, String> {
|
||||
check_bounds(bounds)?;
|
||||
let (path, rule) = resolved(path, rule);
|
||||
let rings = rings(&path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?;
|
||||
let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?;
|
||||
let camera = Camera::new(view, bounds, 0.0);
|
||||
let mut b = Builder { camera: &camera, closed: false, facets: vec![], mesh: mesh.then(Vec::new) };
|
||||
let contours: Vec<Vec<V3>> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::build::{Builder, Built, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward};
|
||||
use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, filled, material};
|
||||
use super::rings::{MAX_SOLID_POINTS, resolved, rings, signed_area};
|
||||
use super::rings::{MAX_SOLID_POINTS, signed_area, solid_rings};
|
||||
use super::{Facet, FillRule, Inflate, MAX_FACES, PathData, Point, Rect, V3, Vec2, View};
|
||||
|
||||
/// A shape to inflate: its rings and the membrane under them on the grid of the bounds.
|
||||
@@ -18,8 +18,7 @@ struct Puff {
|
||||
|
||||
/// The rings of `path` and its membrane on the grid of `bounds`; `None` for a shape with no area.
|
||||
fn puff(path: &PathData, rule: FillRule, bounds: Rect, o: &Inflate) -> Result<Option<Puff>, String> {
|
||||
let (path, rule) = resolved(path, rule);
|
||||
let rings = rings(&path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
|
||||
let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
|
||||
if rings.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
@@ -25,17 +25,107 @@ fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 {
|
||||
const DEBRIS: f64 = 1e-6;
|
||||
|
||||
/// `path` with its overlaps resolved under `rule`: simple contours that do not cross,
|
||||
/// holes running against their outlines. Type often draws one glyph as overlapping contours (a
|
||||
/// "k"'s arm across its stem), and [`rings`] tells a contour's side from one point of it, so a
|
||||
/// contour that crosses another must be cut where they meet first. Every contour that fills area
|
||||
/// is kept, however narrow ([`DEBRIS`]). A path the boolean refuses is used as it is.
|
||||
pub(super) fn resolved(path: &PathData, rule: FillRule) -> (PathData, FillRule) {
|
||||
/// holes running against their outlines. Every contour that fills area is kept, however narrow
|
||||
/// ([`DEBRIS`]). A path the boolean refuses is used as it is.
|
||||
fn resolved(path: &PathData, rule: FillRule) -> (PathData, FillRule) {
|
||||
match vectorcraft_pathops::try_unite_all_with(&[(path, rule)], DEBRIS) {
|
||||
Ok(p) if !p.subpaths.is_empty() => (p, FillRule::NonZero),
|
||||
_ => (path.clone(), rule),
|
||||
}
|
||||
}
|
||||
|
||||
/// The rings of a solid's profile, as [`rings`], with crossing contours resolved first. Type often
|
||||
/// draws one glyph as overlapping contours (a "k"'s arm across its stem), and [`rings`] tells a
|
||||
/// contour's side from one point of it, so a contour that meets another, or itself, must be cut
|
||||
/// where they meet. Only then does the profile go through the boolean ([`resolved`]): most art
|
||||
/// has no such contours, and the boolean costs more than the rest of the solid. The `limit` is
|
||||
/// checked on the profile as it is, before the boolean, and again on what it gives.
|
||||
pub(super) fn solid_rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result<Vec<Vec<Point>>, String> {
|
||||
let sampled = sample(path, size, limit)?;
|
||||
if !edges_meet(&sampled) {
|
||||
return orient(sampled, rule);
|
||||
}
|
||||
let (path, rule) = resolved(path, rule);
|
||||
rings(&path, rule, size, limit)
|
||||
}
|
||||
|
||||
/// Edges closer than this meet: the floor of the boolean's own snapping, so contours it would join
|
||||
/// are resolved and contours it would leave apart are not.
|
||||
const TOUCH: f64 = 1e-6;
|
||||
|
||||
/// Most pairs of edges [`edges_meet`] compares. Past it the answer is yes, and the boolean, with
|
||||
/// its own budget, decides.
|
||||
const MAX_EDGE_PAIRS: usize = 1 << 22;
|
||||
|
||||
/// Do any two edges of `rings` meet, within [`TOUCH`]: a contour crossing, touching or running
|
||||
/// along another, or itself? Neighbouring edges of a ring share a corner and meet only where one
|
||||
/// folds back along the other. Edges are swept in order of their left ends, so only pairs whose
|
||||
/// bounds overlap across x are compared.
|
||||
pub(super) fn edges_meet(rings: &[Vec<Point>]) -> bool {
|
||||
struct Edge {
|
||||
ring: usize,
|
||||
index: usize,
|
||||
a: Point,
|
||||
b: Point,
|
||||
x0: f64,
|
||||
x1: f64,
|
||||
}
|
||||
let mut edges: Vec<Edge> = rings
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(ring, r)| {
|
||||
r.iter().zip(r.iter().cycle().skip(1)).enumerate().map(move |(index, (a, b))| Edge {
|
||||
ring,
|
||||
index,
|
||||
a: *a,
|
||||
b: *b,
|
||||
x0: a.x.min(b.x),
|
||||
x1: a.x.max(b.x),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
edges.sort_by(|e, f| e.x0.total_cmp(&f.x0));
|
||||
let len = |ring: usize| rings.get(ring).map_or(0, Vec::len);
|
||||
let mut pairs = 0usize;
|
||||
for (i, e) in edges.iter().enumerate() {
|
||||
for f in edges.iter().skip(i + 1) {
|
||||
if f.x0 > e.x1 + TOUCH {
|
||||
break;
|
||||
}
|
||||
pairs += 1;
|
||||
if pairs > MAX_EDGE_PAIRS {
|
||||
return true;
|
||||
}
|
||||
if e.a.y.min(e.b.y) > f.a.y.max(f.b.y) + TOUCH || f.a.y.min(f.b.y) > e.a.y.max(e.b.y) + TOUCH {
|
||||
continue;
|
||||
}
|
||||
let n = len(e.ring);
|
||||
let neighbours = e.ring == f.ring && ((e.index + 1) % n == f.index || (f.index + 1) % n == e.index);
|
||||
if neighbours {
|
||||
// They share a corner; they meet only if one runs back along the other.
|
||||
let (shared, p, q) = if e.b == f.a { (e.b, e.a, f.b) } else { (e.a, e.b, f.a) };
|
||||
if super::build::segment_distance(q, shared, p) <= TOUCH || super::build::segment_distance(p, shared, q) <= TOUCH {
|
||||
return true;
|
||||
}
|
||||
} else if segments_meet(e.a, e.b, f.a, f.b) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Do segments `a`–`b` and `c`–`d` cross, or come within [`TOUCH`] of each other?
|
||||
fn segments_meet(a: Point, b: Point, c: Point, d: Point) -> bool {
|
||||
let side = |p: Point, q: Point, r: Point| (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x);
|
||||
let (d1, d2, d3, d4) = (side(c, d, a), side(c, d, b), side(a, b, c), side(a, b, d));
|
||||
if ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0)) && ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0)) {
|
||||
return true;
|
||||
}
|
||||
use super::build::segment_distance as dist;
|
||||
dist(a, c, d).min(dist(b, c, d)).min(dist(c, a, b)).min(dist(d, a, b)) <= TOUCH
|
||||
}
|
||||
|
||||
/// The rings of a shape: closed polygons with the filled side on their left, (-dy, dx): outer
|
||||
/// outlines run one way and holes the other, so nonzero winding fills them as the art is. Curves
|
||||
/// are followed within a tolerance of 1/2000 of `size` (at least 0.05 pt), so small curves take
|
||||
@@ -45,6 +135,12 @@ pub(super) fn resolved(path: &PathData, rule: FillRule) -> (PathData, FillRule)
|
||||
/// is left out: a contour inside another that runs the same way doesn't cut a hole, as it would
|
||||
/// if nesting alone decided (#1724).
|
||||
pub(super) fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result<Vec<Vec<Point>>, String> {
|
||||
orient(sample(path, size, limit)?, rule)
|
||||
}
|
||||
|
||||
/// The closed polygons that follow `path`'s subpaths, as [`rings`] samples them, each running as
|
||||
/// its subpath does.
|
||||
fn sample(path: &PathData, size: f64, limit: usize) -> Result<Vec<Vec<Point>>, String> {
|
||||
let tolerance = (size / 2000.0).max(0.05);
|
||||
let mut count = 0;
|
||||
let mut out = vec![];
|
||||
@@ -79,6 +175,12 @@ pub(super) fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) ->
|
||||
out.push(ring);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `out` (sampled rings) turned so the filled side is on each one's left, and under the nonzero
|
||||
/// `rule` without the rings that are no edge of the shape, as [`rings`] describes.
|
||||
fn orient(mut out: Vec<Vec<Point>>, rule: FillRule) -> Result<Vec<Vec<Point>>, String> {
|
||||
// Which rings hold which: each ring against the others, its edges walked only where its
|
||||
// bounds hold the point, and within [`MAX_CONTAINMENT_EDGES`] edges and pairs in all (a pair
|
||||
// whose bounds miss the point costs a step too: many small rings are a square of pairs).
|
||||
|
||||
@@ -777,7 +777,9 @@ fn crossing_contours_make_one_outline() {
|
||||
let f = extrude(&k, rule, b, Extrude::default(), front()).unwrap();
|
||||
assert_eq!(f.len(), 1, "{rule:?}: straight on, only the front cap shows");
|
||||
assert!(close(area(&f), filled, 1e-6), "{rule:?}: Extrude's cap");
|
||||
assert!(inflate(&k, rule, b, Inflate::default(), front()).is_ok_and(|f| close(extent(&f).width(), 70.0, 1e-6)), "{rule:?}: Inflate");
|
||||
let puffed = inflate(&k, rule, b, Inflate::default(), front()).unwrap();
|
||||
assert_eq!(extent(&puffed), b, "{rule:?}: Inflate covers the whole k");
|
||||
assert!(close(area(&puffed), filled, filled * 1e-3), "{rule:?}: Inflate's front, {} against {filled}", area(&puffed));
|
||||
}
|
||||
// The filled area is the union's: the stem, the arm past it and the leg past the arm.
|
||||
assert!(close(vectorcraft_pathops::area(&k, FillRule::NonZero), 20.0 * 100.0 + 40.0 * 20.0 + 20.0 * 50.0 - 10.0 * 10.0, 1e-6));
|
||||
@@ -790,13 +792,22 @@ fn resolving_overlaps_keeps_narrow_contours() {
|
||||
let rect = |r: Rect| {
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)], true)
|
||||
};
|
||||
// A hairline beside a square, and two bars crossing elsewhere so the profile is resolved.
|
||||
let hairline = Rect::new(120.0, 0.0, 120.005, 50.0);
|
||||
let p = PathData::new(vec![rect(Rect::new(0.0, 0.0, 100.0, 100.0)), rect(hairline)]);
|
||||
let p = PathData::new(vec![
|
||||
rect(Rect::new(0.0, 0.0, 100.0, 100.0)),
|
||||
rect(hairline),
|
||||
rect(Rect::new(140.0, 40.0, 200.0, 60.0)),
|
||||
rect(Rect::new(160.0, 20.0, 180.0, 80.0)),
|
||||
]);
|
||||
let b = p.bounds().unwrap();
|
||||
let filled = 100.0 * 100.0 + hairline.width() * hairline.height();
|
||||
let filled = |rule: FillRule| {
|
||||
let bars = if rule == FillRule::NonZero { 60.0 * 20.0 + 20.0 * 40.0 } else { 60.0 * 20.0 + 20.0 * 40.0 - 20.0 * 20.0 };
|
||||
100.0 * 100.0 + hairline.width() * hairline.height() + bars
|
||||
};
|
||||
for rule in [FillRule::NonZero, FillRule::EvenOdd] {
|
||||
assert!(close(area(&plane(&p, rule, b, front()).unwrap()), filled, 1e-6), "{rule:?}: Rotate");
|
||||
assert!(close(area(&extrude(&p, rule, b, Extrude::default(), front()).unwrap()), filled, 1e-6), "{rule:?}: Extrude's cap");
|
||||
assert!(close(area(&plane(&p, rule, b, front()).unwrap()), filled(rule), 1e-6), "{rule:?}: Rotate");
|
||||
assert!(close(area(&extrude(&p, rule, b, Extrude::default(), front()).unwrap()), filled(rule), 1e-6), "{rule:?}: Extrude's cap");
|
||||
}
|
||||
// A circle drawn twice, exactly and a hair off, is one outline: no sliver between the copies.
|
||||
let circle = |cx: f64, cy: f64, r: f64| {
|
||||
@@ -814,6 +825,71 @@ fn resolving_overlaps_keeps_narrow_contours() {
|
||||
twice.extend(circle(50.0 + dx, 50.0, 40.0).iter());
|
||||
let p = PathData::from_bezpath(&twice);
|
||||
let f = plane(&p, FillRule::NonZero, p.bounds().unwrap(), front()).unwrap();
|
||||
assert_eq!(f.len(), 1, "{dx}");
|
||||
assert_eq!(f[0].contours.len(), 1, "{dx}");
|
||||
let once = PathData::from_bezpath(&circle(50.0, 50.0, 40.0));
|
||||
let one = area(&plane(&once, FillRule::NonZero, once.bounds().unwrap(), front()).unwrap());
|
||||
assert!(close(area(&f), one, one * 1e-6), "{dx}: the area of one circle, {} against {one}", area(&f));
|
||||
}
|
||||
}
|
||||
|
||||
/// Only a profile whose contours meet goes through the boolean: crossing, touching, running along
|
||||
/// another or folding back on itself. Separate and nested contours are taken as they are.
|
||||
#[test]
|
||||
fn contours_meet_when_they_cross_touch_or_fold_back() {
|
||||
let ring = |pts: &[(f64, f64)]| pts.iter().map(|&(x, y)| Point::new(x, y)).collect::<Vec<_>>();
|
||||
let square = |x: f64, y: f64, s: f64| ring(&[(x, y), (x + s, y), (x + s, y + s), (x, y + s)]);
|
||||
assert!(!rings::edges_meet(&[square(0.0, 0.0, 10.0), square(20.0, 0.0, 10.0)]), "apart");
|
||||
assert!(!rings::edges_meet(&[square(0.0, 0.0, 10.0), square(3.0, 3.0, 4.0)]), "nested, as an o's hole");
|
||||
assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(5.0, 5.0, 10.0)]), "crossing");
|
||||
assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)]), "sharing an edge");
|
||||
assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0, 3.0, 4.0)]), "along part of an edge");
|
||||
assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0, 10.0, 4.0)]), "at a corner");
|
||||
assert!(rings::edges_meet(&[square(0.0, 0.0, 10.0), square(10.0 + 1e-7, 0.0, 10.0)]), "within the boolean's snapping");
|
||||
assert!(rings::edges_meet(&[ring(&[(0.0, 0.0), (10.0, 10.0), (10.0, 0.0), (0.0, 10.0)])]), "a bowtie crossing itself");
|
||||
assert!(rings::edges_meet(&[ring(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (10.0, 5.0), (0.0, 10.0)])]), "folding back on itself");
|
||||
// Squares sharing an edge inflate as one puff, not two with a seam between them.
|
||||
let shared = PathData::new(
|
||||
[Rect::new(0.0, 0.0, 50.0, 50.0), Rect::new(50.0, 0.0, 100.0, 50.0)]
|
||||
.iter()
|
||||
.map(|r| {
|
||||
vectorcraft_geom::SubPath::polyline(
|
||||
&[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)],
|
||||
true,
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let whole = PathData::from_bezpath(&{
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((0.0, 0.0));
|
||||
b.line_to((100.0, 0.0));
|
||||
b.line_to((100.0, 50.0));
|
||||
b.line_to((0.0, 50.0));
|
||||
b.close_path();
|
||||
b
|
||||
});
|
||||
let b = whole.bounds().unwrap();
|
||||
let puff = |p: &PathData| inflate(p, FillRule::NonZero, b, Inflate::default(), front()).unwrap().len();
|
||||
assert_eq!(puff(&shared), puff(&whole));
|
||||
}
|
||||
|
||||
/// A profile the boolean can't resolve is used as it is: one with no area builds nothing, as it did,
|
||||
/// and one with an invalid coordinate is refused, as it was.
|
||||
#[test]
|
||||
fn a_profile_the_boolean_refuses_is_taken_as_it_is() {
|
||||
let flat =
|
||||
PathData::new(vec![vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 10.0), Point::new(20.0, 20.0)], true)]);
|
||||
let crossing_flat = PathData::new(vec![
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(10.0, 10.0), Point::new(20.0, 20.0)], true),
|
||||
vectorcraft_geom::SubPath::polyline(&[Point::new(0.0, 20.0), Point::new(10.0, 10.0), Point::new(20.0, 0.0)], true),
|
||||
]);
|
||||
let b = Rect::new(0.0, 0.0, 20.0, 20.0);
|
||||
for p in [&flat, &crossing_flat] {
|
||||
assert_eq!(area(&plane(p, FillRule::NonZero, b, front()).unwrap()), 0.0);
|
||||
assert_eq!(area(&extrude(p, FillRule::NonZero, b, Extrude::default(), front()).unwrap()), 0.0);
|
||||
}
|
||||
let mut bad = crossing_flat.clone();
|
||||
bad.subpaths[0].anchors[1].p = Point::new(f64::NAN, 10.0);
|
||||
assert_eq!(plane(&bad, FillRule::NonZero, b, front()).unwrap_err(), "The shape contains invalid coordinates");
|
||||
}
|
||||
|
||||
+10
-3
@@ -17,6 +17,11 @@ Preview changes are temporary until OK; Cancel restores the document; OK is one
|
||||
3D effect: edit its options in the Appearance panel or the 3D and Materials panel, or remove it before applying
|
||||
another.
|
||||
|
||||
Each solid follows its shape as it is filled. Separate and nested contours are built as drawn. Contours that cross,
|
||||
touch or run along each other or themselves, as type often draws a glyph, are first merged into one outline by
|
||||
[`vectorcraft-pathops`](../crates/pathops/README.md), keeping contours however narrow. All three effects then share
|
||||
its [limits](../crates/pathops/README.md#limits): a shape it refuses is built from its contours as drawn, where it can be.
|
||||
|
||||
## 3D and Materials panel
|
||||
|
||||
**Window › 3D and Materials** opens a panel that edits the 3D effect of the selection without a dialog, as the
|
||||
@@ -62,8 +67,7 @@ object is where the art was.
|
||||
| Bevel Inside | The bevel carved into the shape (the front face shrinks) instead of added around it (the walls stand outside the art). |
|
||||
| Bevel both sides | The back is bevelled too. |
|
||||
|
||||
Holes stay holes, with walls of their own. Contours that cross or overlap, as type often draws a glyph, are merged
|
||||
first, so the solid follows the shape as it is filled. Bevels move each corner along its bisector (mitres are limited to four
|
||||
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.
|
||||
|
||||
## Inflate
|
||||
@@ -229,7 +233,9 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used.
|
||||
effect per object.
|
||||
- Curves are followed within 1/2000 of the shape's size (at least 0.05 pt), so a small curve takes a few points
|
||||
and a large one up to 32. Extrude & Bevel and Inflate take up to 2,048 outline points (a stroke's outline is
|
||||
counted on its own), Rotate (which only turns the outline) 65,536.
|
||||
counted on its own), Rotate (which only turns the outline) 65,536. The limit is counted on the shape as drawn and,
|
||||
when its contours meet, again on the merged outline, which can have more points: bars crossing in a grid trace
|
||||
every hole between them.
|
||||
- **3D (Classic)** (the track cube, surface types, blend steps, map art), Turntable and 3D export are separate,
|
||||
unbuilt items.
|
||||
|
||||
@@ -239,6 +245,7 @@ Release measurements and a repeatable CLI scene are in [3D lighting performance]
|
||||
|
||||
| Date | Change |
|
||||
|---|---|
|
||||
| 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. |
|
||||
| 2026-10-17 | Smooth colored Inflate lighting and surface batching; legacy white-light appearance and the 0.05 seam minimum preserved; shared light Color and multiple lights remain vector output. |
|
||||
|
||||
Reference in New Issue
Block a user