mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 08:29:14 +00:00
Merge pull request #7009 from dotCooCoo/fix/3d-clip-image-outside-shape
3D: an image inside a clip's box but outside its shape is left out
This commit is contained in:
@@ -8,7 +8,7 @@ use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeKind};
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect};
|
||||
use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath};
|
||||
use vectorcraft_pathops as po;
|
||||
|
||||
use super::{MAX_MEMBERS, Scope, geometry, is_container, members, profile, region, reshape_members, solid_paints};
|
||||
@@ -24,6 +24,19 @@ const MAX_DEPTH: usize = 64;
|
||||
/// many steps.
|
||||
pub(super) const MAX_WORK: usize = 1 << 18;
|
||||
|
||||
/// The most times resolving one 3D effect's art may cut an image's frame to a clipping path to
|
||||
/// learn whether the clip shows any of it ([`Resolver::hidden`]).
|
||||
pub(super) const MAX_IMAGE_CUTS: usize = 1024;
|
||||
|
||||
/// The area, in square points, under which what a clip shows of an image's frame counts as nothing:
|
||||
/// the sliver left where the frame only touches the clipping path.
|
||||
const SHOWN_AREA: f64 = 1e-9;
|
||||
|
||||
/// The precision an image's frame is cut to a clipping path with: the cut drops pieces narrower
|
||||
/// than this, so it is far below [`po::DEFAULT_PRECISION`], and a strip of the image much
|
||||
/// narrower than a hundredth of a point still counts as shown.
|
||||
const CUT_PRECISION: f64 = 1e-6;
|
||||
|
||||
/// The error for an image a 3D effect is asked to build from.
|
||||
pub(crate) fn image_refusal(label: &str) -> String {
|
||||
format!("{label} can't build a solid from an image: trace it first with Object › Image Trace › Make.")
|
||||
@@ -66,7 +79,7 @@ pub(crate) fn resolve_for<'a>(doc: Option<&Document>, n: &'a Node, label: &str,
|
||||
if !needs(n) {
|
||||
return Ok(Cow::Borrowed(n));
|
||||
}
|
||||
let mut r = Resolver { doc, label, every_fill, leaves: 0, work: 0, shown: None };
|
||||
let mut r = Resolver { doc, label, every_fill, leaves: 0, work: 0, cuts: 0, shown: None };
|
||||
let children = r.content(n, 0);
|
||||
#[cfg(test)]
|
||||
WORK.with(|w| w.set(r.work));
|
||||
@@ -90,8 +103,18 @@ struct Resolver<'a> {
|
||||
leaves: usize,
|
||||
/// The objects visited or made so far, counted against [`MAX_WORK`].
|
||||
work: usize,
|
||||
/// Inside clipping groups, the box of what their clipping paths show together.
|
||||
shown: Option<Rect>,
|
||||
/// The image frames cut to clipping paths so far, counted against [`MAX_IMAGE_CUTS`].
|
||||
cuts: usize,
|
||||
/// Inside clipping groups, what their clipping paths show together.
|
||||
shown: Option<Shown>,
|
||||
}
|
||||
|
||||
/// The clipping paths around the art being listed, outermost first, with the box of what they
|
||||
/// show together.
|
||||
#[derive(Clone)]
|
||||
struct Shown {
|
||||
bounds: Rect,
|
||||
clips: Vec<Arc<(PathData, FillRule)>>,
|
||||
}
|
||||
|
||||
impl Resolver<'_> {
|
||||
@@ -111,15 +134,52 @@ impl Resolver<'_> {
|
||||
/// `f` run on the art of `c`. The effects of an object or group can move its art into a clip,
|
||||
/// so inside one with effects no image is left out for lying outside the clip ([`Self::shown`]).
|
||||
fn inside<T>(&mut self, c: &Node, f: impl FnOnce(&mut Self) -> Result<T, String>) -> Result<T, String> {
|
||||
let outer = self.shown;
|
||||
if has_effects(c) {
|
||||
self.shown = None;
|
||||
if !has_effects(c) {
|
||||
return f(self);
|
||||
}
|
||||
let outer = self.shown.take();
|
||||
let out = f(self);
|
||||
self.shown = outer;
|
||||
out
|
||||
}
|
||||
|
||||
/// Does the plain image `c` lie wholly outside what the clips around it show together, each
|
||||
/// clipping path with its own fill rule? Its frame, the pixel rectangle as its transform places
|
||||
/// it, is cut to each clip in turn until nothing is left. An error when a cut fails or the cuts
|
||||
/// pass [`MAX_IMAGE_CUTS`]: the image may show, so it can't be left out.
|
||||
fn hidden(&mut self, c: &Node) -> Result<bool, String> {
|
||||
let Some(shown) = &self.shown else { return Ok(false) };
|
||||
let NodeKind::Image(im) = &c.kind else { return Ok(false) };
|
||||
let Some(b) = c.visual_bounds() else { return Ok(true) };
|
||||
if b.intersect(shown.bounds).area() <= 0.0 {
|
||||
return Ok(true);
|
||||
}
|
||||
let (w, h) = (f64::from(im.width), f64::from(im.height));
|
||||
let corners = [(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)].map(|(x, y)| im.xf * Point::new(x, y));
|
||||
if corners.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) {
|
||||
return Err(image_refusal(self.label));
|
||||
}
|
||||
let clips = shown.clips.clone();
|
||||
let mut left = PathData::single(SubPath::polyline(&corners, true));
|
||||
for clip in clips {
|
||||
let Some(here) = clip.0.bounds() else { return Ok(true) };
|
||||
if left.bounds().is_none_or(|l| l.intersect(here).area() <= 0.0) {
|
||||
return Ok(true);
|
||||
}
|
||||
self.cuts += 1;
|
||||
if self.cuts > MAX_IMAGE_CUTS {
|
||||
return Err(self.too_much_work());
|
||||
}
|
||||
self.spend(1)?;
|
||||
left =
|
||||
po::try_boolean(&left, FillRule::NonZero, &clip.0, clip.1, po::BoolOp::Intersect, CUT_PRECISION).map_err(|_| self.too_complex())?;
|
||||
if left.subpaths.iter().all(|s| s.area().abs() <= SHOWN_AREA) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// The art `n`, a symbol instance, a blend, a clipping group or layer, or a group holding one,
|
||||
/// shows, as the members of a plain group.
|
||||
fn content(&mut self, n: &Node, depth: usize) -> Result<Vec<Arc<Node>>, String> {
|
||||
@@ -162,11 +222,15 @@ impl Resolver<'_> {
|
||||
if clip_nested_past(first, MAX_DEPTH.saturating_sub(depth)) {
|
||||
return Err(self.too_deep());
|
||||
}
|
||||
let clip = clip_area(first);
|
||||
// An image wholly outside every clip around it shows nothing and is left out.
|
||||
let outer = self.shown;
|
||||
let clip = Arc::new(clip_area(first));
|
||||
// An image wholly outside what the clips around it show together draws nothing and
|
||||
// is left out.
|
||||
let here = clip.0.bounds().unwrap_or(Rect::ZERO);
|
||||
self.shown = Some(outer.map_or(here, |o| o.intersect(here)));
|
||||
let outer = self.shown.take();
|
||||
let mut inner_shown = outer.clone().unwrap_or(Shown { bounds: here, clips: vec![] });
|
||||
inner_shown.bounds = inner_shown.bounds.intersect(here);
|
||||
inner_shown.clips.push(clip.clone());
|
||||
self.shown = Some(inner_shown);
|
||||
let listed = self.list(rest, depth + 1);
|
||||
self.shown = outer;
|
||||
let (inner, _) = listed?;
|
||||
@@ -198,8 +262,7 @@ impl Resolver<'_> {
|
||||
// Only an image with no effects or fills and strokes of its own is measured by its
|
||||
// frame; any other one may draw inside the clip.
|
||||
let plain = !has_effects(c) && c.appearance.items.iter().all(|i| !i.visible());
|
||||
let hidden = plain && self.shown.is_some_and(|k| c.visual_bounds().is_none_or(|b| b.intersect(k).area() <= 0.0));
|
||||
if hidden {
|
||||
if plain && self.hidden(c)? {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -317,6 +317,86 @@ fn images_are_refused_alone_and_in_a_group_with_image_trace_named() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A clipping group: `art` inside a clipping path of `path` filled under `rule`.
|
||||
fn shaped_clip_group(id: u64, path: PathData, rule: FillRule, art: Vec<Node>) -> Node {
|
||||
let mut clipping = Node::path(NodeId(id + 1), path, Appearance::default());
|
||||
if let NodeKind::Path { clipping: c, rule: r, .. } = &mut clipping.kind {
|
||||
*c = true;
|
||||
*r = rule;
|
||||
}
|
||||
let children = std::iter::once(clipping).chain(art).map(Arc::new).collect();
|
||||
Node::new(NodeId(id), NodeKind::Group { children, clip: true })
|
||||
}
|
||||
|
||||
/// An image `width` by `height` pixels placed by `xf`.
|
||||
fn placed(id: u64, width: u32, height: u32, xf: Affine) -> Node {
|
||||
let mut img = image(id);
|
||||
if let NodeKind::Image(i) = &mut img.kind {
|
||||
i.width = width;
|
||||
i.height = height;
|
||||
i.xf = xf;
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_inside_a_clips_box_but_outside_its_shape_is_left_out() {
|
||||
let d = document();
|
||||
let circle = || shapes::ellipse(Rect::new(100.0, 100.0, 200.0, 200.0));
|
||||
let square = || shapes::rectangle(Rect::new(100.0, 100.0, 150.0, 150.0));
|
||||
let at = |x: f64, y: f64| Affine::translate((x, y));
|
||||
// Two circles inside each other's clips: they show together only the lens between them.
|
||||
let lens = |art: Vec<Node>| {
|
||||
let inner = shaped_clip_group(70, shapes::ellipse(Rect::new(120.0, 100.0, 220.0, 200.0)), FillRule::NonZero, art);
|
||||
shaped_clip_group(80, shapes::ellipse(Rect::new(80.0, 100.0, 180.0, 200.0)), FillRule::NonZero, vec![inner])
|
||||
};
|
||||
let mut ring = shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0));
|
||||
ring.subpaths.extend(shapes::rectangle(Rect::new(130.0, 130.0, 170.0, 170.0)).subpaths);
|
||||
// Turned 45° about its center at (156, 156): its box reaches into the square's corner, and its
|
||||
// frame does not.
|
||||
let turned = at(156.0, 156.0) * Affine::rotate(std::f64::consts::FRAC_PI_4) * at(-5.0, -5.0);
|
||||
// A shown rectangle and an image `w` by `h` at `xf` inside the clipping path `path`.
|
||||
let clipped = |path: PathData, rule: FillRule, shown: Rect, (w, h, xf): (u32, u32, Affine)| {
|
||||
shaped_clip_group(60, path, rule, vec![rect(62, shown.x0, shown.y0, shown.x1, shown.y1, 0.0), placed(63, w, h, xf)])
|
||||
};
|
||||
let middle = Rect::new(140.0, 140.0, 160.0, 160.0);
|
||||
let band = Rect::new(100.0, 100.0, 110.0, 200.0);
|
||||
let left_out = [
|
||||
// In the corner of the circle's box, outside the circle.
|
||||
("a circle's corner", clipped(circle(), FillRule::NonZero, middle, (10, 10, at(100.0, 100.0)))),
|
||||
("a turned frame", clipped(square(), FillRule::NonZero, Rect::new(110.0, 110.0, 140.0, 140.0), (10, 10, turned))),
|
||||
// Above the lens: inside each circle, outside both at once.
|
||||
("two clips' shared area", lens(vec![rect(62, 145.0, 140.0, 155.0, 160.0, 0.0), placed(63, 8, 3, at(146.0, 100.0))])),
|
||||
("an even-odd hole", clipped(ring.clone(), FillRule::EvenOdd, band, (10, 10, at(140.0, 140.0)))),
|
||||
];
|
||||
let shown = [
|
||||
("part of the circle", clipped(circle(), FillRule::NonZero, middle, (10, 10, at(145.0, 145.0)))),
|
||||
("a non-zero hole", clipped(ring, FillRule::NonZero, band, (10, 10, at(140.0, 140.0)))),
|
||||
// A strip 0.005 pt wide inside the square still shows.
|
||||
("a narrow strip", clipped(square(), FillRule::NonZero, Rect::new(110.0, 110.0, 140.0, 140.0), (10, 10, at(149.995, 120.0)))),
|
||||
("the lens", lens(vec![rect(62, 145.0, 140.0, 155.0, 160.0, 0.0), placed(63, 8, 3, at(146.0, 108.0))])),
|
||||
];
|
||||
for id in TYPES {
|
||||
for (what, g) in &left_out {
|
||||
let n = with(g.clone(), id, json!({}));
|
||||
assert_eq!(validate_three_d_in(Some(&d), &n, id, &json!({})), Ok(()), "{id}: {what}");
|
||||
assert!(art(&d, &n, id).is_some(), "{id}: {what} draws");
|
||||
assert!(three_d_meshes_in(Some(&d), &n).is_ok_and(|m| !m.is_empty()), "{id}: {what} exports");
|
||||
}
|
||||
for (what, g) in &shown {
|
||||
let n = with(g.clone(), id, json!({}));
|
||||
assert!(validate_three_d_in(Some(&d), &n, id, &json!({})).is_err_and(|e| e.contains("Image Trace")), "{id}: {what}");
|
||||
assert!(art(&d, &n, id).is_none(), "{id}: {what}");
|
||||
}
|
||||
}
|
||||
// Each frame cut to a clip counts: past the limit the art is refused, never left out unchecked.
|
||||
let many: Vec<Node> = std::iter::once(rect(62, 140.0, 140.0, 160.0, 160.0, 0.0))
|
||||
.chain((0..=source::MAX_IMAGE_CUTS as u64).map(|i| placed(100 + i, 10, 10, at(100.0, 100.0))))
|
||||
.collect();
|
||||
let n = with(shaped_clip_group(60, circle(), FillRule::NonZero, many), ROTATE, front());
|
||||
assert!(validate_three_d_in(Some(&d), &n, ROTATE, &json!({})).is_err_and(|e| e.contains("too many objects")));
|
||||
}
|
||||
|
||||
/// Symbols `L0` to `L{levels}`: `L0` holds `leaf`, each later one `fan` instances of the one
|
||||
/// before it.
|
||||
fn nested_symbols(levels: usize, fan: usize, leaf: Node) -> Document {
|
||||
|
||||
@@ -572,6 +572,7 @@ since 2026-10-01.
|
||||
- Window › 3D and Materials panel: `crates/ui-egui/src/panels/three_d.rs` applies a 3D effect by object type (Plane, Extrude, Revolve, Inflate), changes an applied one in place (`effect.setParams` `threeDType`) and edits it with the options dialog's Object, Rotation and Lighting controls, one undo step per change; `crates/engine/src/cmd/effectcmd/three_d_type_tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. No Materials tab.
|
||||
- 3D and Materials › Quick Actions › Expand as Wireframes (#6354): `effect.expandAsWireframes` replaces each selected 3D object with a group of 0.5 pt black stroked paths, the edges of the faces its effect draws, each edge once and joined end to end, curves kept, in one undo step ([three-d.md](three-d.md#3d-and-materials-panel)); `crates/effects/src/solids/wireframe/tests.rs`, `crates/engine/src/cmd/wireframe/tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. Scores and hours unchanged.
|
||||
- 3D effects on clipping groups, blends and symbol instances (#6140, #5725): every 3D type builds from the art the object shows (the art inside the clipping path, the blend's keys and steps, the symbol's art where the instance places it), alone or in a group, on the canvas, in exports, Object › Rasterize and `assets.add3D`; an image is refused with a pointer to Object › Image Trace › Make. `crates/effects/src/solids/tests_sources.rs`, `crates/engine/src/cmd/effectcmd/three_d_source_tests.rs` and `fx::tests::a_symbol_instance_a_blend_and_a_clipping_group_draw_their_solids` cover it. Scores and estimates unchanged.
|
||||
- 3D effects, images a clip hides: an image in a clipping group is left out of the solid when its transformed frame lies wholly outside the area every clipping path around it fills together, holes included, instead of when only the boxes miss; an image the clips show part of is still refused ([the help page](https://helpx.adobe.com/illustrator/using/clipping-masks.html) on clipping paths hiding everything outside their shape; the omission rule is VectorCraft's own). `solids::tests_sources::an_image_inside_a_clips_box_but_outside_its_shape_is_left_out` covers it. Scores and estimates unchanged.
|
||||
|
||||
- **Status-bar pointer readout (2026-10-18):** uses the same effective ruler origin as the position fields, then formats the relative X/Y in General units. The absolute pointer position, document, selection, Undo/Redo and saved state are preserved. Three `chrome::tests::status_coordinates_*` painted-text regressions cover zero/positive/negative origins, unit conversion, global/artboard mode, active artboard, reset and missing pointer/document. Original headless status-bar captures were reviewed before and after the change. Scores and hours are unchanged.
|
||||
|
||||
|
||||
+7
-3
@@ -177,9 +177,13 @@ solids are ([Groups](#groups)), turned about one shared axis.
|
||||
- The objects stay live: editing the clipping path, the blend's keys or the symbol reshapes the solid.
|
||||
- An image is refused, alone or in a group: trace it first with Object › Image Trace › Make. The help page routes
|
||||
images to 3D through Image Trace ([Create 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-vector-artwork.html)).
|
||||
A hidden image in a group is left out like any hidden member, and so is an image in a clipping group whose bounds
|
||||
lie wholly outside the clipping path's bounds, when the image has no visible fills or strokes of its own and
|
||||
neither the image nor a group holding it inside the clipping group has effects.
|
||||
A hidden image in a group is left out like any hidden member. An image in a clipping group is left out when its
|
||||
frame, as its transform places it, lies wholly outside the area that every clipping path around it fills together
|
||||
(holes of an even-odd or compound clipping path included), when the image has no visible fills or strokes of its
|
||||
own and neither the image nor a group holding it inside the clipping group has effects. A clipping path hides
|
||||
everything outside its shape ([the help page](https://helpx.adobe.com/illustrator/using/clipping-masks.html)). An
|
||||
image that the clips show any part of is refused. Resolving one effect's art cuts image frames to clipping paths at
|
||||
most 1,024 times (VectorCraft's own limit); past that, the art is refused.
|
||||
|
||||
## Rotate
|
||||
|
||||
|
||||
Reference in New Issue
Block a user