diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index 2aa218e558..f3fda46685 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -3,7 +3,7 @@ use vectorcraft_geom::kurbo::ParamCurveDeriv; use super::outline::{self, Along, Budget, CURVE_TOLERANCE}; use super::rings::{ CURVE_PIECES, MAX_PIECES, MAX_PLANE_POINTS, MAX_SOLID_POINTS, Ring, Span, TOO_COMPLEX_SURFACE, curve_pieces, inset, left, signed_area, - solid_rings_traced, + solid_rings_traced, square_corners, }; use super::{ Bevel, DepthPlane, Extrude, Facet, FillRule, MAX_FACES, MAX_SOLID_FACES, ParamCurve, PathData, PlanarFace, Point, Rect, Revolve, V3, View, @@ -458,10 +458,15 @@ pub(super) fn check_bounds(b: Rect) -> Result<(), String> { } /// The art's outward direction at edge `i` of `ring` (before rotation), tilted towards the face -/// it borders by `z` (+1 front, -1 back, 0 a side wall). +/// it borders by `z` (+1 front, -1 back, 0 a side wall). An edge of no length (a corner cut +/// square, see [`square_corners`]) faces out between the edges on either side of it. pub(super) fn outward(ring: &[Point], i: usize, z: f64) -> V3 { let n = ring.len(); - let (lx, ly) = left(ring[i], ring[(i + 1) % n]); + let (mut lx, mut ly) = left(ring[i], ring[(i + 1) % n]); + if lx == 0.0 && ly == 0.0 && n > 2 { + let (a, b) = (left(ring[(i + n - 1) % n], ring[i]), left(ring[(i + 1) % n], ring[(i + 2) % n])); + (lx, ly) = (a.0 + b.0, a.1 + b.1); + } V3(-lx, -ly, z) } @@ -607,7 +612,10 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio let (bands, caps) = (steps.len().saturating_sub(1), if o.cap { 2 } else { 0 }); let size = bounds.width().max(bounds.height()); let most = curve_pieces(path, size, bands, caps)?; - let traced = solid_rings_traced(path, rule, size, MAX_SOLID_POINTS, most)?; + // Every inset of one extrusion goes the same way: into the shape for a bevel inside, out of it + // for one outside. The corners that way opens are cut square past the miter limit. + let side = steps.iter().map(|s| s.inset).find(|w| *w != 0.0).unwrap_or(0.0); + let traced: Vec = solid_rings_traced(path, rule, size, MAX_SOLID_POINTS, most)?.iter().map(|r| square_corners(r, side)).collect(); let rings: Vec<&[Point]> = traced.iter().map(|r| r.points.as_slice()).collect(); let edges = rings.iter().map(|r| r.len()).fold(0usize, usize::saturating_add); if edges.saturating_mul(bands).saturating_add(caps) > MAX_SOLID_FACES { diff --git a/crates/three-d/src/solid/outline.rs b/crates/three-d/src/solid/outline.rs index 07fb0ce58b..93d9d70340 100644 --- a/crates/three-d/src/solid/outline.rs +++ b/crates/three-d/src/solid/outline.rs @@ -123,7 +123,8 @@ fn contour(path: &mut BezPath, ring: &Ring, points: &[Point], step: &Along, budg while i < n { let end = |k: usize| points.get((k + 1) % n).copied().unwrap_or(first); let Some(span) = ring.spans.get(i).copied().flatten() else { - if i + 1 < n { + // A corner cut square (see `square_corners`) whose two copies did not move apart. + if i + 1 < n && points.get(i).copied() != Some(end(i)) { path.line_to(end(i)); } i += 1; diff --git a/crates/three-d/src/solid/rings.rs b/crates/three-d/src/solid/rings.rs index 80dc66407c..882bd245af 100644 --- a/crates/three-d/src/solid/rings.rs +++ b/crates/three-d/src/solid/rings.rs @@ -16,6 +16,10 @@ pub(super) const CURVE_PIECES: u32 = 32; /// for fewer takes fewer. pub(super) const MIN_CURVE_PIECES: u32 = 4; +/// How far an inset corner's miter reaches at most, as a multiple of the inset. [`square_corners`] +/// cuts a corner whose miter would reach further square across instead. +const MITER_LIMIT: f64 = 4.0; + const TOO_COMPLEX_SHAPE: &str = "The shape is too complex for a 3D effect"; /// The error a solid past its limits returns, and a 3D effect whose solids together pass /// [`crate::MAX_OBJECT_FACES`]. @@ -472,8 +476,10 @@ pub(super) fn left(a: Point, b: Point) -> (f64, f64) { } /// `ring` moved `w` points towards its filled side (negative: away from it), each corner along -/// its bisector, its mitre limited to four times `w` so sharp corners don't shoot off. Each point -/// stays the partner of its source point, so rings at different insets join face to face. +/// its bisector, its miter limited to [`MITER_LIMIT`] times `w` so sharp corners don't shoot off. +/// A point given twice (a corner [`square_corners`] cut) moves each copy along its one edge's +/// normal. Each point stays the partner of its source point, so rings at different insets join +/// face to face. pub(super) fn inset(ring: &[Point], w: f64) -> Vec { if w == 0.0 { return ring.to_vec(); @@ -486,6 +492,12 @@ pub(super) fn inset(ring: &[Point], w: f64) -> Vec { let next = ring[(i + 1) % n]; let (ax, ay) = left(prev, p); let (bx, by) = left(p, next); + if ax == 0.0 && ay == 0.0 { + return Point::new(p.x + bx * w, p.y + by * w); + } + if bx == 0.0 && by == 0.0 { + return Point::new(p.x + ax * w, p.y + ay * w); + } let (mut mx, mut my) = (ax + bx, ay + by); let ml = mx.hypot(my); if ml < 1e-9 { @@ -493,8 +505,43 @@ pub(super) fn inset(ring: &[Point], w: f64) -> Vec { } else { (mx, my) = (mx / ml, my / ml); } - let cos = (mx * ax + my * ay).max(0.25); + let cos = (mx * ax + my * ay).max(1.0 / MITER_LIMIT); Point::new(p.x + mx * w / cos, p.y + my * w / cos) }) .collect() } + +/// `ring` with each corner whose miter an inset towards `side` (positive: the filled side, +/// negative: away from it) would reach past [`MITER_LIMIT`] given twice, joined by an edge of no +/// length that follows no curve. [`inset`] moves the two copies apart along their own edges' +/// normals, so the inset ring cuts the corner square, as a bevel join does, instead of running out +/// to a point along the bisector. Corners on the other side of the turn, whose inset edges meet +/// before the corner, keep one point. +pub(super) fn square_corners(ring: &Ring, side: f64) -> Ring { + let n = ring.points.len(); + if side == 0.0 || n < 3 || ring.spans.len() != n { + return ring.clone(); + } + let mut out = Ring { points: Vec::with_capacity(n), spans: Vec::with_capacity(n) }; + for (i, (&p, span)) in ring.points.iter().zip(&ring.spans).enumerate() { + let prev = ring.points[(i + n - 1) % n]; + let next = ring.points[(i + 1) % n]; + let (a, b) = (left(prev, p), left(p, next)); + let (mx, my) = (a.0 + b.0, a.1 + b.1); + let ml = mx.hypot(my); + // The cosine of the half angle between the bisector and each edge's normal: the miter + // reaches 1 / cos times the inset. + let cos = if ml < 1e-9 { 0.0 } else { (mx * a.0 + my * a.1) / ml }; + // Which way the outline turns at `p`: an inset towards `side` opens the corner when the + // turn is against it. + let turn = (p - prev).cross(next - p); + let edges = (a.0 != 0.0 || a.1 != 0.0) && (b.0 != 0.0 || b.1 != 0.0); + out.points.push(p); + if edges && cos < 1.0 / MITER_LIMIT && turn * side < 0.0 { + out.spans.push(None); + out.points.push(p); + } + out.spans.push(*span); + } + out +} diff --git a/crates/three-d/src/solid/tests.rs b/crates/three-d/src/solid/tests.rs index 335f434419..7afce495ad 100644 --- a/crates/three-d/src/solid/tests.rs +++ b/crates/three-d/src/solid/tests.rs @@ -255,6 +255,78 @@ fn a_bevel_inside_shrinks_the_cap_and_outside_grows_the_walls() { assert!(inside.iter().any(|f| !close(f.brightness, inside[inside.len() - 1].brightness, 1e-3))); } +/// A bevel past a sharp corner is cut square, as a bevel join cuts a stroke, instead of running +/// out to a spike along the corner's bisector: seen straight on, no point of the solid lies further +/// from the art than the miters of its corners of about 90° (14.6 pt for a 10 pt bevel), where the +/// sharp corner's spike reached 40 pt. The bevel opens the corner of a thin spike (with its +/// neighbor in the same compound path) when it is outside, and of a narrow notch when it is inside; +/// the other two cases, where the inset edges meet, keep their limited miters and are checked too. +#[test] +fn a_bevel_cuts_sharp_corners_square() { + let poly = |pts: &[(f64, f64)]| { + let mut b = BezPath::new(); + b.move_to(pts[0]); + pts[1..].iter().for_each(|p| b.line_to(*p)); + b.close_path(); + b + }; + let spike = [(50.0, 0.0), (56.0, 100.0), (44.0, 100.0)]; + let block = [(80.0, 40.0), (120.0, 40.0), (120.0, 100.0), (80.0, 100.0)]; + let notched = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (52.0, 100.0), (50.0, 20.0), (48.0, 100.0), (0.0, 100.0)]; + let mut both = poly(&spike); + both.extend(poly(&block).elements().iter().copied()); + let bevel = Bevel { width: 10.0, height: 10.0, ..Bevel::default() }; + let mut far_cases = vec![]; + for (name, art, outline) in [("spike", both, vec![spike.to_vec(), block.to_vec()]), ("notch", poly(¬ched), vec![notched.to_vec()])] { + let (p, b) = shape(&art); + let outline: Vec> = outline.iter().map(|r| r.iter().map(|q| Point::new(q.0, q.1)).collect()).collect(); + for inside in [false, true] { + for shape in [BevelShape::Classic, BevelShape::Rounded] { + let o = Extrude { bevel: Some(Bevel { inside, shape, ..bevel }), ..Extrude::default() }; + let f = extrude(&p, FillRule::NonZero, b, o, front()).unwrap(); + let far = f.iter().flat_map(|f| f.contours.iter().flatten()).map(|q| edge_distance(&outline, *q)).fold(0.0, f64::max); + if far > 15.0 { + far_cases.push(format!("{name} inside {inside} {shape:?}: a point {far} pt from the art")); + } + assert!(f.iter().all(|f| f.normal.iter().all(|v| v.is_finite())), "{name} inside {inside} {shape:?}"); + } + // Turned, every face still has a normal it can be lit and sorted by. + let o = Extrude { bevel: Some(Bevel { inside, ..bevel }), ..Extrude::default() }; + let f = extrude(&p, FillRule::NonZero, b, o, View::default()).unwrap(); + assert!(f.iter().all(|f| f.normal.iter().all(|v| v.is_finite()) && f.brightness.is_finite()), "{name} inside {inside}"); + } + } + assert!(far_cases.is_empty(), "{far_cases:#?}"); +} + +/// With the bevel outside, the cap is the outline itself, so a corner cut square keeps its two +/// points together there: the cap's outline draws no straight segment of no length at that corner. +#[test] +fn a_cap_draws_no_empty_segment_at_a_corner_cut_square() { + use vectorcraft_geom::kurbo::PathEl; + let mut art = BezPath::new(); + art.move_to((50.0, 0.0)); + art.line_to((56.0, 100.0)); + art.curve_to((56.0, 120.0), (44.0, 120.0), (44.0, 100.0)); + art.close_path(); + let (p, b) = shape(&art); + let o = Extrude { bevel: Some(Bevel { width: 10.0, height: 10.0, ..Bevel::default() }), ..Extrude::default() }; + let f = extrude(&p, FillRule::NonZero, b, o, front()).unwrap(); + let cap = f.iter().find(|f| f.depth == f64::MAX && f.outline.is_some()).unwrap(); + let mut at = None; + for el in cap.path().to_bezpath().elements() { + match *el { + PathEl::MoveTo(q) => at = Some(q), + PathEl::LineTo(q) => { + assert_ne!(at, Some(q), "a segment of no length at {q:?}"); + at = Some(q); + } + PathEl::QuadTo(_, q) | PathEl::CurveTo(_, _, q) => at = Some(q), + PathEl::ClosePath => {} + } + } +} + #[test] fn bevel_shapes_repeats_and_both_sides_change_the_surface() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cdbbec3254..9361cc00de 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1279,6 +1279,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Revolve and Revolve (Classic) draw two overlapping faces of a surface in the order of their depths where they overlap, instead of by the depths of their centers, on one object and across a group's members, so a tilted profile with long edges or a profile of several contours no longer draws a nearer face under a farther one. A surface whose faces were already in the right order keeps it. Tests: `solids::tests_revolve_order::a_revolved_surface_draws_the_nearer_of_two_overlapping_faces_last`, `solids::tests_revolve_order::a_revolved_groups_members_draw_the_nearer_of_two_overlapping_faces_last`, `solids::tests_revolve_order::a_classic_revolved_surface_draws_the_nearer_of_two_overlapping_facets_last`, `solids::tests_revolve_order::a_surface_already_in_order_keeps_it`, `solids::tests_revolve_order::surface_order_puts_the_nearer_face_where_they_overlap_last`, `tests::a_revolved_face_measures_its_depth_on_its_plane`. - Under a colored light, an Inflate fill and strokes in solid colors, opaque with Normal blending, also draw their faces in that one order, inside a group member's own opacity mask too, instead of each solid as one composite with the stroke's faces over every fill face. Each face keeps the composite's paint, light ramps and seam width; a lone solid, the solids of an object with a translucent or blended fill or stroke, and such solids holding more than 30,720 faces together (VectorCraft's own limit) are still drawn as one composite each. Tests: `solids::tests::colored_solid_fills_and_strokes_share_face_depth_order`, `solids::tests::a_colored_solid_face_on_its_own_keeps_the_composites_colors`, `solids::tests::colored_solid_inflate_batches_channels_and_clips_once_per_surface`, `solids::tests::colored_solids_past_the_face_limit_draw_as_composites`. - Without a twist, a tapered Extrude & Bevel wall (and each bevel band) is one flat face; only a twist cuts walls into slices, so no seam between slices paints a light tick or a light line on a tapered, untwisted wall (#7019). Test: `solid::tests::a_tapered_wall_is_one_flat_face_and_a_twisted_one_is_sliced`. + - Extrude & Bevel cuts a corner square where the bevel opens it and its miter would reach past four times the width (a sharp point with the bevel outside, a narrow notch with it inside), so the bevel (and, with the bevel outside, the walls) no longer runs out to a spike that does not line up with the art (#7525). Tests: `solid::tests::a_bevel_cuts_sharp_corners_square`, `solid::tests::a_cap_draws_no_empty_segment_at_a_corner_cut_square`. - 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`). diff --git a/docs/three-d.md b/docs/three-d.md index 6de07fc2fe..92d425ec9d 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -85,8 +85,10 @@ 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. 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. Each facet has a seam ([Seams](#seams)). +Holes stay holes, with walls of their own. Bevels move each corner along its bisector. A corner the bevel opens (a +sharp point with the bevel outside, a narrow notch with it inside) whose miter would reach past four times the width is +cut square across instead; at the other corners the miter is limited to four times the width. A bevel wider than a +narrow part of the shape folds over itself. Each facet has a seam ([Seams](#seams)). ## Inflate