diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 1165340bc..7025f8f6f 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -155,18 +155,87 @@ fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> { crate::lighting::validate(p)?; let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…')); - let (path, rule) = - profile(n).ok_or_else(|| format!("{label} currently requires a path or compound path. Convert type, images and groups to paths first."))?; - let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; if let Some(v) = p.get("bevel") && !v.as_str().is_some_and(|s| s == "none" || BevelShape::parse(s).is_some()) { let names: Vec<_> = BevelShape::ALL.iter().map(|b| b.id()).collect(); return Err(format!("bevel must be none or one of {}", names.join(", "))); } + if is_container(n) { + let shapes = members(n, label)?; + let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?; + return shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ())); + } + let (path, rule) = profile(n).ok_or_else(|| { + format!("{label} currently requires a path, compound path, type or group. Convert images and other objects to paths first.") + })?; + let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; facets(id, &path, rule, b, p).map(|_| ()) } +/// One shape a solid is built from: its outline and fill rule, the fills and strokes that paint +/// it, and the opacity of the objects it is in (1 for the object with the effect itself). +struct Profile { + path: PathData, + rule: FillRule, + items: Vec, + opacity: f32, +} + +/// Is `n` a group or layer (a 3D effect on it turns its members about one shared axis)? +fn is_container(n: &Node) -> bool { + matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. }) +} + +/// The bounds of every shape together: the centre a group's members turn about. +fn union_bounds(shapes: &[Profile]) -> Option { + shapes.iter().filter_map(|s| s.path.bounds()).reduce(|a, c| a.union(c)) +} + +/// The most members a 3D effect on a group builds solids from. +const MAX_MEMBERS: usize = 4096; + +/// The shapes a 3D effect on group or layer `n` builds its solids from: every visible path, +/// compound path and type in it, at any depth of its groups, each through its own geometry +/// effects and painted with its own fills and strokes. Like the effect on a group in the +/// reference app, they turn about one shared axis (the centre of their bounds together) rather +/// than each about its own. An error names what the group holds that can't become a solid. +fn members(n: &Node, label: &str) -> Result, String> { + fn walk(n: &Node, opacity: f32, label: &str, out: &mut Vec) -> Result<(), String> { + for c in n.children().into_iter().flatten() { + if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) { + continue; + } + let opacity = opacity * c.opacity; + if is_container(c) { + walk(c, opacity, label, out)?; + continue; + } + let Some((path, rule)) = profile(c) else { + let what = match &c.kind { + NodeKind::Group { clip: true, .. } => "clipping groups", + NodeKind::Image(_) => "images", + NodeKind::SymbolInstance { .. } => "symbol instances", + NodeKind::Path { clipping: true, .. } => "clipping paths", + _ => "objects other than paths, compound paths and type", + }; + return Err(format!("{label} on a group needs paths, compound paths and type: release or expand its {what} first.")); + }; + if out.len() >= MAX_MEMBERS { + return Err(format!("{label} on a group takes at most {MAX_MEMBERS} objects")); + } + let path = geometry(&path, &c.appearance.effects, &GeomContext::of(c)); + if path.bounds().is_some() { + out.push(Profile { path, rule, items: solid_paints(c), opacity }); + } + } + Ok(()) + } + let mut out = vec![]; + walk(n, 1.0, label, &mut out)?; + Ok(out) +} + /// Can 3D effect `id` (Revolve included, [`is_three_d`]) with parameters `p` draw `n`? Checked /// before a command changes the document: Revolve by [`crate::validate_revolve`], the others by /// [`validate_solid`]. @@ -226,10 +295,11 @@ fn surface(n: &Node, facets: Vec, paint: &Paint, opacity: f32, blend: Ble group } -fn paint_of(item: &AppearanceItem) -> (Paint, f32, BlendMode) { +/// `item`'s paint, its opacity times `member` (the opacity of the objects it is in) and its blend. +fn paint_of(item: &AppearanceItem, member: f32) -> (Paint, f32, BlendMode) { match item { - AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity, f.blend), - AppearanceItem::Stroke(s) => (s.paint.clone(), s.opacity, s.blend), + AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity * member, f.blend), + AppearanceItem::Stroke(s) => (s.paint.clone(), s.opacity * member, s.blend), } } @@ -249,46 +319,50 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData, /// 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); /// otherwise each solid is a surface of its own, in that order. -fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&AppearanceItem]) -> Option>> { - let b = path.bounds()?; +fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> { + let b = union_bounds(shapes)?; let p = crate::merged_params(&fx.id, &fx.params); let colored = view_options(&p).has_colored_lights(); if fx.id == ROTATE { - // Every part turns about the centre of the object's own bounds, so they stay together. + // Every part turns about the centre of the object's own bounds (a group's members about + // the centre of theirs), so they stay together. let mut out = vec![]; - for item in items { - let (paint, opacity, blend) = paint_of(item); - let (area, rule) = region(path, rule, item); - let f = facets(ROTATE, &area, rule, b, &p).ok()?; - out.push(Arc::new(surface(n, f, &paint, opacity, blend, colored))); + for s in shapes { + for item in &s.items { + let (paint, opacity, blend) = paint_of(item, s.opacity); + let (area, rule) = region(&s.path, s.rule, item); + let f = facets(ROTATE, &area, rule, b, &p).ok()?; + out.push(Arc::new(surface(n, f, &paint, opacity, blend, colored))); + } } return Some(out); } - let top_fill = items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_))); - let parts: Vec<(&AppearanceItem, PathData, FillRule)> = items + // Each shape's top fill and its strokes, a group's members from the back up. + let parts: Vec<(&AppearanceItem, f32, PathData, FillRule)> = shapes .iter() - .enumerate() - .filter(|(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_))) - .map(|(_, i)| { - let (area, rule) = region(path, rule, i); - (*i, area, rule) + .flat_map(|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)| { + let (area, rule) = region(&s.path, s.rule, i); + (i, s.opacity, area, rule) + }) }) .collect(); // The solids share the bounds of all their shapes: one center to turn, twist and taper about, // and Inflate's grid reaches over the strokes' outlines. - let bounds = parts.iter().filter_map(|(_, area, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(b); + let bounds = parts.iter().filter_map(|(_, _, area, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(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, _, _), f) in parts.iter().zip(built) { + for ((item, opacity, _, _), f) in parts.iter().zip(built) { let item: &AppearanceItem = item; match f { - Ok(f) => solids.push((item, f)), + Ok(f) => solids.push((item, *opacity, f)), // 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 @@ -300,15 +374,15 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App if solids.is_empty() { return None; } - let opaque = solids.iter().all(|(item, _)| { - let (_, opacity, blend) = paint_of(item); + let opaque = solids.iter().all(|(item, member, _)| { + let (_, opacity, blend) = paint_of(item, *member); opacity >= 1.0 && blend == BlendMode::Normal }); let surfaces: Vec<(Vec, Node)> = solids .into_iter() - .map(|(item, f)| { + .map(|(item, member, f)| { let depths: Vec = f.iter().map(|f| f.depth).collect(); - let (paint, opacity, blend) = paint_of(item); + let (paint, opacity, blend) = paint_of(item, member); (depths, surface(n, f, &paint, opacity, blend, colored)) }) .collect(); @@ -338,19 +412,23 @@ fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData { /// `n` drawn with its Extrude & Bevel, Inflate or Rotate effect: a group of shaded faces keeping /// the object's id and transparency, the source path untouched. `None` without such an effect. +/// On a group or layer the effect builds a solid from each path, compound path and type in it, +/// all turned about one shared axis ([`members`]). pub fn solid_art(n: &Node) -> Option { if !has_solid(n) { return None; } + if is_container(n) { + return group_solid_art(n); + } let (path, rule) = profile(n)?; let ctx = GeomContext::of(n); let paints = solid_paints(n); - let painted: Vec<&AppearanceItem> = paints.iter().collect(); let mut children = vec![]; let remaining: Vec; if let Some((index, fx)) = solid(&n.appearance.effects) { let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx); - children.extend(art_for(n, fx, &path, rule, &painted)?); + children.extend(art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0 }])?); // Later geometry effects act on the projected art as a whole. remaining = n .appearance @@ -363,16 +441,16 @@ pub fn solid_art(n: &Node) -> Option { } else { let path = geometry(&path, &n.appearance.effects, &ctx); remaining = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); - for item in &painted { + for item in &paints { if let Some((index, fx)) = solid(item.effects()) { let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item)); - for mut art in art_for(n, fx, &p, rule, &[*item])? { + for mut art in art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0 }])? { Arc::make_mut(&mut art).appearance.effects = item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect(); children.push(art); } } else { - children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![(*item).clone()], ..Default::default() }))); + children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![item.clone()], ..Default::default() }))); } } } @@ -382,5 +460,29 @@ pub fn solid_art(n: &Node) -> Option { Some(art) } +/// [`solid_art`] of group or layer `n`: its object-level 3D effect on its members together. The +/// geometry effects before it reshape the members, those after it bend the result as a whole; the +/// group's own fills and strokes are not drawn. `None` when the effect is only on one of the +/// group's own fills or strokes, or when its members can't become solids. +fn group_solid_art(n: &Node) -> Option { + let (index, fx) = solid(&n.appearance.effects)?; + let mut shapes = members(n, "").ok()?; + let before = n.appearance.effects.get(..index)?; + if before.iter().any(|e| e.visible && is_geometry(&e.id)) { + let b = union_bounds(&shapes)?; + let ctx = GeomContext::of(n); + for s in &mut shapes { + s.path = apply_geometry_with(before, &s.path, b, &ctx); + } + } + let children = art_for(n, fx, &shapes)?; + let remaining = + n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); + let mut art = n.clone(); + art.kind = NodeKind::Group { children, clip: false }; + art.appearance = Appearance { effects: remaining, ..Default::default() }.into(); + Some(art) +} + #[cfg(test)] mod tests; diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 9dd72d4b1..a9997bb5d 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -462,8 +462,8 @@ fn parameters_are_validated_and_lengths_scale_with_the_object() { let n = square_node(); assert!(validate_solid(&n, EXTRUDE, &json!({"bevel": "rounded", "depth": 80})).is_ok()); assert!(validate_solid(&n, EXTRUDE, &json!({"bevel": "fancy"})).unwrap_err().contains("bevel must be")); - let text = Node::group(NodeId(2), vec![]); - assert!(validate_solid(&text, INFLATE, &json!({})).unwrap_err().contains("requires a path")); + let empty = Node::group(NodeId(2), vec![]); + assert!(validate_solid(&empty, INFLATE, &json!({})).unwrap_err().contains("requires a group holding a shape")); for (id, key) in [(EXTRUDE, "depth"), (EXTRUDE, "bevelWidth"), (EXTRUDE, "bevelHeight"), (INFLATE, "depth")] { assert!(crate::is_length(id, key, false), "{id} {key}"); } @@ -540,3 +540,58 @@ fn colored_solid_inflate_batches_channels_and_clips_once_per_surface() { assert_eq!(clips, surfaces); assert_eq!(groups, 1 + 6 * surfaces, "group count must not grow with triangle count"); } + +/// `square_node` moved right by `dx`, with id `id` and fill `fill`. +fn square_at(id: u64, dx: f64, fill: Color) -> Node { + let mut n = square_node(); + n.id = NodeId(id); + n.transform(Affine::translate((dx, 0.0)), false); + n.appearance.items = Appearance::basic(Paint::solid(fill), Paint::None, 0.0).items; + n +} + +#[test] +fn a_3d_effect_on_a_group_turns_every_member_about_one_shared_axis() { + // Two squares side by side, 100 pt apart, in a group: 100–200 and 300–400 across. + let group = |id: &str, params: Value| { + let mut g = Node::group(NodeId(9), vec![Arc::new(square_at(1, 0.0, RED)), Arc::new(square_at(2, 200.0, BLUE))]); + g.appearance.effects.push(fx(id, params)); + g + }; + let turned = json!({"rotationX": 0, "rotationY": 60, "rotationZ": 0}); + for id in SOLIDS { + let g = group(id, json!({})); + assert!(validate_solid(&g, id, &json!({})).is_ok(), "{id}"); + let art = solid_art(&g).unwrap_or_else(|| panic!("{id}")); + assert_eq!(art.id, NodeId(9), "{id}: the art is the group's"); + assert!(art.appearance.effects.iter().all(|e| !is_three_d(&e.id)), "{id}"); + // Both members draw, each in its own fill. + let mut colours = vec![]; + art.walk(&mut |c| { + if c.path_data().is_some() { + colours.push(fill_rgb(c)); + } + }); + assert!(colours.iter().any(|c| c[2] > c[0]) && colours.iter().any(|c| c[0] > c[2]), "{id}: red and blue faces"); + } + // Turned 60° about y, the members turn together about the group's centre (x = 250): the + // pair spans half its width; turned each about its own centre they would stay 100 pt apart. + let b = solid_art(&group(ROTATE, turned.clone())).unwrap().visual_bounds().unwrap(); + assert!((b.center().x - 250.0).abs() < 1e-3, "{b:?}"); + assert!((b.width() - 300.0 * 0.5).abs() < 1.0, "{b:?}"); + let mut alone = square_at(2, 200.0, BLUE); + alone.appearance.effects.push(fx(ROTATE, turned)); + let own = solid_art(&alone).unwrap().visual_bounds().unwrap(); + assert!((own.center().x - 350.0).abs() < 1e-3, "{own:?}"); + // Hidden members and guides are left out; a group holding what can't become a solid is + // refused with what to do. + let mut g = group(EXTRUDE, json!({})); + let mut clip = Node::group(NodeId(5), vec![Arc::new(square_at(6, 0.0, RED))]); + clip.kind = NodeKind::Group { children: clip.children().unwrap().clone(), clip: true }; + if let NodeKind::Group { children, .. } = &mut g.kind { + children.push(Arc::new(clip)); + } + let err = validate_solid(&g, EXTRUDE, &json!({})).unwrap_err(); + assert!(err.contains("clipping groups"), "{err}"); + assert!(solid_art(&g).is_none(), "drawn as the group it is"); +} diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index f4c0073a3..a823ee161 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -754,7 +754,10 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br // keeps it as its only member). Some((image, false)) => Some(rasterfx::replace_with_image(v, image)), image => { - let m = if container { + let m = if container && effects::has_solid(&v) { + // A group's or layer's 3D effect: the shaded faces of its members' solids. + effects::expand_leaf(d, &v, &mut stroke_art) + } else if container { // A group's or layer's own fills and strokes become art among its members. effects::evaluate_container(Some(&*d), &v).map(|mut m| { members = expand_pieces(d, &mut m, false, brushes, &mut stroke_art); diff --git a/crates/engine/src/cmd/effectcmd/tests.rs b/crates/engine/src/cmd/effectcmd/tests.rs index ce8f51183..be1dd443c 100644 --- a/crates/engine/src/cmd/effectcmd/tests.rs +++ b/crates/engine/src/cmd/effectcmd/tests.rs @@ -79,6 +79,41 @@ fn solid_effects_apply_save_edit_export_expand_and_undo() { } } +/// #4506: a 3D effect on a group builds a solid from each member, turned about one shared axis; +/// the members stay editable, and a group holding what can't become a solid is refused. +#[test] +fn solid_effects_apply_to_a_group_and_its_members_stay_editable() { + for effect in effects::SOLIDS { + let (mut s, a) = session_with_rect(); + let b = s.execute("shape.ellipse", &json!({"x": 250, "y": 120, "width": 80, "height": 60})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect": effect, "ids": [g.0], "item": null})).unwrap(); + let art = effects::solid_art(&node(&s, g)).unwrap_or_else(|| panic!("{effect}")); + assert_eq!(node(&s, g).children().unwrap().len(), 2, "{effect}: the members stay as they were"); + // A member moved, the solid follows. + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("object.move", &json!({"dx": -40, "dy": 0})).unwrap(); + assert_ne!(effects::solid_art(&node(&s, g)).unwrap(), art, "{effect}"); + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + let svg = svg["data"].as_str().or_else(|| svg["text"].as_str()).map(str::to_string).unwrap_or_else(|| svg.to_string()); + assert!(vectorcraft_testkit::svg_shapes(&svg) >= 4, "{effect}: both members exported as vector faces"); + s.execute("effect.expandAppearance", &json!({"ids": [g.0]})).unwrap(); + assert!(!effects::has_solid(&node(&s, g)), "{effect}: expanded"); + } + // A clipping group in the group: refused, nothing changed. + let (mut s, a) = session_with_rect(); + let b = s.execute("shape.ellipse", &json!({"x": 120, "y": 120, "width": 60, "height": 60})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap(); + let clip = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].clone(); + let c = s.execute("shape.rectangle", &json!({"x": 250, "y": 250, "width": 50, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [clip, c]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [g.0], "item": null})).unwrap_err().to_string(); + assert!(err.contains("clipping groups"), "{err}"); + assert!(node(&s, g).appearance.effects.is_empty()); +} + /// #4715: type extrudes as its glyph outlines, painted with its characters' colour, and stays /// type: editing its text reshapes the solid. #[test] diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 86f04ffa5..8bccf2c36 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -136,6 +136,13 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { members.iter().filter(|c| c.visible).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c))) }; match &n.kind { + // A group's or layer's 3D effect: the art of its members' solids. + NodeKind::Layer { .. } | NodeKind::Group { clip: false, .. } + if effects::has_solid(n) + && let Some(art) = effects::solid_art(n) => + { + drawn_bounds(symbols, &art) + } _ if effects::has_container_appearance(n) => { let art = effects::evaluate_container(Some(symbols.document()), n); let art = art.as_ref().unwrap_or(n); diff --git a/crates/render/src/tests/objectfx.rs b/crates/render/src/tests/objectfx.rs index edc044f0a..b6baab074 100644 --- a/crates/render/src/tests/objectfx.rs +++ b/crates/render/src/tests/objectfx.rs @@ -136,6 +136,38 @@ fn solid_effects_canvas_matches_baked_art_and_shades_their_faces() { } } +/// #4506: a 3D effect on a group draws its members' solids on the canvas as the export does, and +/// the group's drawn bounds (what a change redraws) hold all of them. +#[test] +fn solid_effects_on_a_group_draw_as_exported_within_their_drawn_bounds() { + for (effect, params) in [ + ("threeD.extrude", json!({"depth": 30, "rotationX": -30, "rotationY": 40})), + ("threeD.inflate", json!({"depth": 10})), + ("threeD.rotate", json!({"rotationX": 0, "rotationY": 55, "rotationZ": 0})), + ] { + let square = |id: u64, x: f64, c: Color| { + Arc::new(Node::path( + NodeId(id), + shapes::rectangle(vectorcraft_geom::Rect::new(x, 35.0, x + 25.0, 60.0)), + Appearance::basic(Paint::solid(c), Paint::None, 0.0), + )) + }; + let mut g = Node::group(NodeId(0), vec![square(10, 15.0, Color::rgb(0.9, 0.2, 0.1)), square(11, 60.0, Color::rgb(0.1, 0.3, 0.9))]); + g.appearance.effects.push(fx(effect, params)); + let d = doc_with(Document::new(100.0, 100.0), g); + let live = render(&d); + let exported = render(&vectorcraft_effects::bake_document(&d).unwrap()); + assert_eq!(live.pixels, exported.pixels, "{effect}: canvas and baked/exported faces agree"); + let g = &d.layers[0].children().unwrap()[0]; + let art = vectorcraft_effects::solid_art(g).unwrap().visual_bounds().unwrap(); + let drawn = drawn_bounds(&d, g).unwrap(); + assert_eq!(drawn.union(art), drawn, "{effect}: the drawn bounds hold the art"); + let blues = live.pixels.as_chunks::<4>().0.iter().filter(|p| p[2] as u16 > p[0] as u16 * 2).count(); + let reds = live.pixels.as_chunks::<4>().0.iter().filter(|p| p[0] as u16 > p[2] as u16 * 2).count(); + assert!(blues > 100 && reds > 100, "{effect}: both members drawn ({reds} red, {blues} blue px)"); + } +} + #[test] fn smooth_revolve_reduces_banding_without_changing_radial_resolution() { let path = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_svg("M70 20 L70 80").unwrap()); diff --git a/docs/effects-parity.md b/docs/effects-parity.md index bfeb288af..2307add80 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -1,6 +1,6 @@ # Effects parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (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); previously: 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-06 · **Change:** minor (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,7 +179,7 @@ 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, Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | +| 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), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | | Materials panel, lighting, ray-traced render | 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; no materials, shadows or ray tracing | | 3D (Classic): Extrude & Bevel, Revolve, Rotate | none | | Turntable (30.0 beta) | none | @@ -193,6 +193,7 @@ angular cut caps, exact visibility, 3D Classic, Turntable, 3D export) is 25–45 | Date | Change | Summary | |---|---|---| +| 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 | | 2026-11-06 | minor | The Transform effect's copies of a path or compound path whose fills and strokes stack (a fill and a stroke, several of either, an even-odd fill, or a partly transparent or blended one) are drawn each as an object of its own above the one before, as Expand Appearance makes them, instead of one path of every copy whose fill lay under every copy's stroke (#5920). A single opaque stroke or nonzero fill still draws as one path. Counts unchanged. | | 2026-11-04 | minor | Expand Appearance of a path or compound path whose Transform effect makes copies gives a group of one object per copy, each with the path's fills and strokes, instead of one compound path of every copy; copies of copies nest (#5612). Counts unchanged. | | 2026-10-30 | minor | Pixelate › Color Halftone: Max Radius is in pixels of the Document Raster Effects resolution, 4–127 px, as the [filter effects reference](https://helpx.adobe.com/photoshop/using/filter-effects-reference.html) gives it, instead of points (#3260): fine screens are possible at print resolutions (4 px at 300 ppi ≈ 0.96 pt). The dialog and the Appearance panel show it in px whatever the document units; documents saved before (document versions 1 and 2) open with each radius converted, so their dots stay the same size, and a file saved for an older app version (native versions before today's) holds them in points again. Scores unchanged. Tests: `vectorcraft_effects` `color_halftone_sizes_its_dots_in_pixels_of_the_raster_effects_resolution` and `color_halftones_radius_is_in_raster_pixels_not_a_distance`, `vectorcraft_doc` `halftone_radii::tests`, `vectorcraft_format` `halftone_radii_of_older_documents_open_in_pixels`, `vectorcraft_engine` `a_preserve_editing_pdf_opens_with_its_halftone_radius_in_pixels`, `dialogs::tests::color_halftones_max_radius_stays_in_pixels_whatever_the_units`. | diff --git a/docs/three-d.md b/docs/three-d.md index 7b1a25c62..7cc339dff 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -70,6 +70,24 @@ The rest is our reading: grid stays flat. - A stroke whose outline takes more than 2,048 points is left out, and the fill's solid still draws. +## Groups + +Extrude & Bevel, Inflate and Rotate also apply to a group or layer. The public documentation says that applied to +several objects one by one each turns about its own axis, while applied to a group or layer all its objects turn +about a shared axis ([Create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html), +said there of Revolve). So the effect on a group builds a solid from every visible path, compound path and type in +it, at any depth of its groups, each from its own fills and strokes as above ([Fills and strokes](#fills-and-strokes)), +and they turn, twist and taper together about the center of their bounds together; with Perspective one eye sees +them all. The members stay editable: moving or editing one reshapes the solids. Our reading: + +- Each member's own geometry effects shape it first; the group's geometry effects before the 3D effect reshape the + members, and those after it bend the result as a whole. The members' other effects, and the group's own fills and + strokes, are not drawn. A member's opacity applies to its solids; its blending mode does not. +- When every solid is opaque, all members' faces are drawn in one back-to-front order; otherwise each solid is drawn + on its own, the backmost member's first. +- Hidden members and guides are left out. A group holding an image, a symbol instance, a clipping group or another + live object is refused with a message saying what to release or expand; so is one with more than 4,096 shapes. + ## Rotate The flat art turned in 3D about the centre of its bounds. Every fill and stroke is turned with its own paint (a @@ -176,7 +194,9 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used. drawn in pieces about as long as a wall slice is wide (fewer near the limit of 32,768 facets). The piece drawn on top can still lie a few points behind another nearby piece. - Extrude & Bevel and Rotate retain planar shading. Colored Inflate uses smooth vector lighting; white-light Inflate keeps its original polygon shading; bounded cell triangulation leaves a cell flat when it contains a complete hole or an unusually complex/degenerate contour. Its geometric silhouette is still a polygon approximation. -- Paths and compound paths only: convert type, images and groups to outlines first. One 3D effect per object. +- Paths, compound paths, type and groups or layers of them (see [Groups](#groups)); images, symbol instances, + clipping groups and other live objects have to be converted first. Revolve on a group is not built yet. One 3D + 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. @@ -189,5 +209,6 @@ Release measurements and a repeatable CLI scene are in [3D lighting performance] | Date | Change | |---|---| +| 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. |