3D and Materials: Expand as Wireframes expands Revolve (Classic) and Rotate (Classic)

effect.expandAsWireframes returned "draws no faces" for an object with
Revolve (Classic). It now takes the faces of Revolve (Classic) with that
effect's own options, and Rotate (Classic) is expanded as Rotate is: every
fill and stroke turned as one plane.

Docs: docs/mcp.md, docs/three-d.md.
This commit is contained in:
dotCooCoo
2026-11-12 05:36:03 -08:00
parent 01ccb1d8ca
commit 6ccb7c9077
7 changed files with 67 additions and 22 deletions
+14 -10
View File
@@ -507,9 +507,9 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul
Ok(out)
}
/// The outline of every face [`revolve_art`] draws for `n` (its symbol instances, blends and clipping
/// groups already resolved), in document coordinates: with the effect on the object, the surface in
/// its top visible fill (else its stroke); with it on fills and strokes, one surface for each of
/// The outline of every face [`revolve_art`] draws for `n`'s Revolve or Revolve (Classic) (its symbol
/// instances, blends and clipping groups already resolved), in document coordinates: with the
/// effect on the object, the surface in its top visible fill (else its stroke); with it on fills and strokes, one surface for each of
/// them, reshaped by the geometry effects after each one's Revolve; on a group or layer, one for
/// each member, about one shared axis.
pub(crate) fn revolve_face_paths(n: &Node) -> Result<Vec<PathData>, String> {
@@ -517,13 +517,17 @@ pub(crate) fn revolve_face_paths(n: &Node) -> Result<Vec<PathData>, String> {
Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(vectorcraft_three_d::Face::path).collect())
};
if is_container(n) {
let index =
n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE).ok_or("a 3D effect on a group's fill or stroke draws nothing")?;
let index = n
.appearance
.effects
.iter()
.position(|e| e.visible && is_revolve(&e.id))
.ok_or("a 3D effect on a group's fill or stroke draws nothing")?;
let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) };
let mut shapes = members(n, "Revolve")?;
reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes);
let b = union_bounds(&shapes).ok_or("Revolve requires a group holding a shape with at least two anchors")?;
let options = shared_options(&shapes, revolve_options(&merged_params(REVOLVE, &fx.params)))?;
let options = shared_options(&shapes, fx_options(fx))?;
let mut out = vec![];
for s in shapes.iter().filter(|s| surface_item(&s.items).is_some()) {
out.extend(faces(&s.path, b, options)?);
@@ -532,24 +536,24 @@ pub(crate) fn revolve_face_paths(n: &Node) -> Result<Vec<PathData>, String> {
}
let (path, _) = object_profile(n, "Revolve")?;
let ctx = GeomContext::of(n);
if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) {
if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id)) {
let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) };
let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx);
if surface_item(&solid_paints(n)).is_none() {
return Err("the revolved object has no fill or stroke".into());
}
let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?;
return faces(&path, b, revolve_options(&merged_params(REVOLVE, &fx.params)));
return faces(&path, b, fx_options(fx));
}
let path = geometry(&path, &n.appearance.effects, &ctx);
let mut out = vec![];
for item in n.appearance.items.iter().filter(|i| crate::paints(i)) {
if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE)
if let Some(index) = item.effects().iter().position(|e| e.visible && is_revolve(&e.id))
&& let Some(fx) = item.effects().get(index)
{
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item));
let b = p.bounds().ok_or("Revolve requires a shape with at least two anchors")?;
let surface = faces(&p, b, revolve_options(&merged_params(REVOLVE, &fx.params)))?;
let surface = faces(&p, b, fx_options(fx))?;
out.extend(crate::solids::wireframe::reshaped(surface, item.effects().get(index + 1..).unwrap_or_default(), &ctx));
}
}
+7 -7
View File
@@ -7,8 +7,8 @@ use vectorcraft_geom::{Anchor, CubicBez, PathData, Point, SubPath};
use vectorcraft_three_d::Facet;
use super::{
INFLATE, Profile, ROTATE, facets, geometry, has_solid, inflate_options, is_container, is_three_d, label_of, members, object_profile, region,
reshape_members, resolve, solid, solid_paints, solid_parts, union_bounds, view_options,
INFLATE, Profile, ROTATE, facets, geometry, has_solid, inflate_options, is_container, is_plane, is_three_d, label_of, members, object_profile,
region, reshape_members, resolve, solid, solid_paints, solid_parts, union_bounds, view_options,
};
use crate::{GeomContext, apply_geometry_with, is_geometry};
@@ -21,8 +21,8 @@ const GRID: f64 = 1.0 / 1024.0;
const NO_EFFECT: &str = "the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate)";
/// The wireframe of `n`'s 3D effect (Extrude & Bevel, Extrude & Bevel (Classic), Revolve, Inflate
/// or Rotate), in document coordinates: the edges of every face the effect draws for the object as
/// The wireframe of `n`'s 3D effect (Extrude & Bevel, Revolve, Inflate, Rotate or one of their 3D
/// (Classic) forms), in document coordinates: the edges of every face the effect draws for the object as
/// it is turned and projected, each edge once. Edges that meet end to end are joined into one path;
/// a run that comes back to where it started is closed. A curved edge stays a curve. The geometry
/// effects after the object's 3D effect reshape the wireframe as they reshape the drawn art. On a
@@ -108,8 +108,8 @@ fn solid_face_paths(n: &Node) -> Result<Vec<PathData>, String> {
Ok(out)
}
/// The outlines of the facets effect `fx` builds from `shapes`, as `art_for` builds them:
/// Rotate turns every fill and stroke; Extrude & Bevel and Inflate build a solid from each shape's
/// The outlines of the facets effect `fx` builds from `shapes`, as `art_for` builds them: Rotate and
/// Rotate (Classic) turn every fill and stroke; Extrude & Bevel and Inflate build a solid from each shape's
/// top fill and from the outline of each stroke. A stroke whose solid is past the limits is left out,
/// as it is left out of the drawn art.
fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result<Vec<PathData>, String> {
@@ -117,7 +117,7 @@ fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result<Vec<PathData>, String>
let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
let p = crate::merged_params(&fx.id, &fx.params);
let mut out = vec![];
if fx.id == ROTATE {
if is_plane(&fx.id) {
for s in shapes {
for item in &s.items {
let (area, rule) = region(&s.path, s.rule, item);
@@ -124,6 +124,39 @@ fn revolve_inflate_and_rotate_expand_too() {
assert_eq!(segments(&plane).len(), 4);
}
/// Revolve (Classic) and Rotate (Classic) expand to the edges of the faces they draw, in the same
/// place.
#[test]
fn revolve_classic_and_rotate_classic_expand_too() {
let n = solid_square(crate::CLASSIC_REVOLVE, json!({}));
let wire = three_d_wireframe_in(None, &n).unwrap();
assert!(!wire.is_empty());
let art = crate::revolve_art(&n).unwrap();
let (w, a) = (bounds(&wire), bounds(&face_paths(&art)));
for (x, y) in [(w.x0, a.x0), (w.y0, a.y0), (w.x1, a.x1), (w.y1, a.y1)] {
assert!((x - y).abs() < 1e-6, "{w:?} {a:?}");
}
// On a fill, with a geometry effect after it.
let mut n = solid_square(crate::CLASSIC_REVOLVE, json!({}));
let fx = n.appearance.effects.remove(0);
for item in &mut n.appearance.items {
if matches!(item, AppearanceItem::Fill(_)) {
item.effects_mut().push(fx.clone());
}
}
let plain = bounds(&three_d_wireframe_in(None, &n).unwrap());
for item in &mut n.appearance.items {
if matches!(item, AppearanceItem::Fill(_)) {
item.effects_mut().push(crate::new_effect("distort.transform", &json!({"moveH": 50.0})).unwrap());
}
}
let moved = bounds(&three_d_wireframe_in(None, &n).unwrap());
assert!((moved.x0 - plain.x0 - 50.0).abs() < 1e-6 && (moved.y0 - plain.y0).abs() < 1e-6, "{plain:?} {moved:?}");
// Rotate (Classic) is one flat face: its outline.
let plane = three_d_wireframe_in(None, &solid_square(super::super::CLASSIC_ROTATE, json!({}))).unwrap();
assert_eq!(segments(&plane).len(), 4);
}
/// A geometry effect after a fill's 3D effect moves that fill's edges, as it moves its drawn surface.
#[test]
fn a_geometry_effect_after_a_fill_s_3d_effect_reshapes_its_edges() {
+1 -1
View File
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Expand as Wireframes",
[],
None,
"{ids?: [..] (default: the selected objects)} replace each object that has a visible 3D effect (Extrude & Bevel, Extrude & Bevel (Classic), Revolve, Inflate or Rotate, on the object or on its fills and strokes) with a group of paths: the edges of the faces the effect draws, as the object is turned and projected now, each edge once (edges that meet end to end make one path, closed where it comes back to its start; curved edges stay curves; geometry effects after the 3D effect, on the object or on a fill or stroke, reshape them too), stroked 0.5 pt black with round joins and no fill (VectorCraft's own); the group takes the object's place, id, name, opacity, blending mode, opacity mask, attributes, slice and text wrap and is selected (a layer stays a layer holding the edges, which are selected); objects without a visible 3D effect are left as they are; one undo step. An error, with nothing changed, when no object is selected or given, when none has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges → {ids}",
"{ids?: [..] (default: the selected objects)} replace each object that has a visible 3D effect (Extrude & Bevel, Revolve, Inflate, Rotate or one of their 3D (Classic) forms, on the object or on its fills and strokes) with a group of paths: the edges of the faces the effect draws, as the object is turned and projected now, each edge once (edges that meet end to end make one path, closed where it comes back to its start; curved edges stay curves; geometry effects after the 3D effect, on the object or on a fill or stroke, reshape them too), stroked 0.5 pt black with round joins and no fill (VectorCraft's own); the group takes the object's place, id, name, opacity, blending mode, opacity mask, attributes, slice and text wrap and is selected (a layer stays a layer holding the edges, which are selected); objects without a visible 3D effect are left as they are; one undo step. An error, with nothing changed, when no object is selected or given, when none has a visible 3D effect, when an object's solid can't be built or the canvas draws its source art in its place, or when one object's wireframe has more than 1,000,000 edges → {ids}",
has_doc,
expand_as_wireframes
)]
+9 -1
View File
@@ -27,7 +27,15 @@ fn run(s: &mut Session, p: Value) -> crate::Result<Value> {
#[test]
fn a_3d_object_becomes_a_selected_group_of_stroked_edges_in_one_undo_step() {
for effect in ["threeD.extrude", "threeD.classicExtrude", "threeD.revolve", "threeD.inflate", "threeD.rotate"] {
for effect in [
"threeD.extrude",
"threeD.classicExtrude",
"threeD.revolve",
"threeD.classicRevolve",
"threeD.inflate",
"threeD.rotate",
"threeD.classicRotate",
] {
let (mut s, id) = setup(Some(effect));
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().name = Some("Box".into());
let before = s.doc().unwrap().doc.clone();
+2 -2
View File
@@ -3228,8 +3228,8 @@ checked as that effect's. It is one undo step. Any other `threeDType` value, or
## Expand as Wireframes
`effect.expandAsWireframes {ids?}` (**Window › 3D and Materials** › Quick Actions › Expand as Wireframes) replaces
each selected object, or each object in `ids`, that has a visible 3D effect (Extrude & Bevel, Extrude & Bevel
(Classic), Revolve, Inflate or Rotate, on the object or on its fills and strokes) with a group of paths: the edges of
each selected object, or each object in `ids`, that has a visible 3D effect (Extrude & Bevel, Revolve, Inflate,
Rotate or one of their 3D (Classic) forms, on the object or on its fills and strokes) with a group of paths: the edges of
the faces the effect draws for the object as it is turned and projected now
([three-d.md](three-d.md#3d-and-materials-panel)). Each edge appears once; edges that meet end to end form one
path, closed where it comes back to its start, and curved edges stay curves. Geometry effects after a 3D effect,
+1 -1
View File
@@ -52,7 +52,7 @@ pages describe it.
- Each change is one undo step. Dragging a value or a slider previews it on the artboard and records one step
when the pointer is released; a typed value is recorded when it is entered. Drawing the panel records nothing.
- **Quick Actions › Expand as Wireframes** sits under the Object tab (under the first tab for Revolve and Rotate,
which have no Object tab). It replaces each selected object that has a 3D effect with a group of paths, the
which have no Object tab). It replaces each selected object that has a 3D effect, the 3D (Classic) effects included, with a group of paths, the
edges of the faces the effect draws for the object as it is turned and projected now (`effect.expandAsWireframes`),
as the public [Render 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/render-3d-vector-artwork.html)
page describes for converting the wireframe into editable paths. Each edge appears once: edges that meet end to