mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 09:39:52 +00:00
3D and Materials: Expand as Wireframes turns a 3D object into its edges as paths
Window > 3D and Materials > Quick Actions > Expand as Wireframes, and the command effect.expandAsWireframes, replace each selected object that has a visible 3D effect (Extrude & Bevel, Extrude & Bevel (Classic), Revolve, Inflate or Rotate) with a group of paths: the edges of the faces the effect draws for the object as it is turned and projected now. - 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 the 3D effect reshape the edges as they reshape the drawn surface. - The paths have a 0.5 pt black stroke with round joins and no fill. The group keeps the object's id, name, transparency, attributes, slice and text wrap, and is selected. A layer stays a layer holding the edges. - It is one undo step. Nothing selected, no visible 3D effect, a solid that can't be built or is drawn as its source art, or more than 1,000,000 edges in one object is an error, and the document does not change. Docs: docs/mcp.md, docs/three-d.md, docs/effects-parity.md, docs/target-app-parity.md. Fixes #6354
This commit is contained in:
@@ -87,8 +87,8 @@ pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_art_in, revolve_opt
|
||||
pub use solids::{
|
||||
CLASSIC_EXTRUDE, EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options,
|
||||
classic_shading, extrude_options, has_solid, inflate_options, is_three_d, solid_art, solid_art_in, three_d_meshes, three_d_meshes_in,
|
||||
three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, validate_solid, validate_solid_in, validate_three_d, validate_three_d_at,
|
||||
validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options,
|
||||
three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, three_d_wireframe_in, validate_solid, validate_solid_in, validate_three_d,
|
||||
validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options,
|
||||
};
|
||||
pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings};
|
||||
pub use svg_filter::SVG_FILTER;
|
||||
|
||||
@@ -455,6 +455,55 @@ 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
|
||||
/// 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> {
|
||||
let faces = |path: &PathData, b: Rect, options: Revolve| -> 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 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 mut out = vec![];
|
||||
for s in shapes.iter().filter(|s| surface_item(&s.items).is_some()) {
|
||||
out.extend(faces(&s.path, b, options)?);
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
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) {
|
||||
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)));
|
||||
}
|
||||
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)
|
||||
&& 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)))?;
|
||||
out.extend(crate::solids::wireframe::reshaped(surface, item.effects().get(index + 1..).unwrap_or_default(), &ctx));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`revolve_meshes`] of group or layer `n`: one mesh for each member, about one shared axis.
|
||||
fn group_meshes(n: &Node, total: &mut usize, limit: usize) -> Result<Vec<crate::SolidMesh>, String> {
|
||||
let index = n
|
||||
|
||||
@@ -22,6 +22,9 @@ pub(crate) use source::{resolve, resolve_for};
|
||||
|
||||
pub(crate) mod shadow;
|
||||
|
||||
pub(crate) mod wireframe;
|
||||
pub use wireframe::three_d_wireframe_in;
|
||||
|
||||
pub const EXTRUDE: &str = "threeD.extrude";
|
||||
pub const INFLATE: &str = "threeD.inflate";
|
||||
pub const ROTATE: &str = "threeD.rotate";
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
//! Expand as Wireframes: the edges of the faces a 3D effect draws, as open and closed paths that
|
||||
//! can be edited on their own.
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
|
||||
use vectorcraft_doc::{AppearanceItem, Document, Effect, Node};
|
||||
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,
|
||||
};
|
||||
use crate::{GeomContext, apply_geometry_with, is_geometry};
|
||||
|
||||
/// The most distinct edges one object's wireframe may have.
|
||||
pub(crate) const MAX_WIRE_EDGES: usize = 1_000_000;
|
||||
|
||||
/// The grid points are rounded to before edges are compared, in points: two faces that share an
|
||||
/// edge give it once.
|
||||
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
|
||||
/// 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
|
||||
/// group or layer, the edges of the solids of all its members. An error when `n` has no visible 3D
|
||||
/// effect, when its solids can't be built, when the canvas draws the source art in their place, when
|
||||
/// they draw no face, or past [`MAX_WIRE_EDGES`] edges.
|
||||
pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result<Vec<PathData>, String> {
|
||||
if !crate::has_revolve(n) && !has_solid(n) {
|
||||
return Err(NO_EFFECT.into());
|
||||
}
|
||||
let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id));
|
||||
// The faces come from the effect whose art the canvas draws (Revolve first, as the canvas tries
|
||||
// it first). When neither draws, past their limits, the canvas shows the source art and there
|
||||
// is no surface to expand.
|
||||
let revolve = crate::has_revolve(n) && crate::revolve_art_in(doc, n).is_some();
|
||||
if !revolve && !(has_solid(n) && super::solid_art_in(doc, n).is_some()) {
|
||||
let n = &*resolve(doc, n, label)?;
|
||||
// The builders' own error says why, when there is one.
|
||||
if crate::has_revolve(n) {
|
||||
crate::revolve::revolve_face_paths(n)?;
|
||||
} else {
|
||||
solid_face_paths(n)?;
|
||||
}
|
||||
return Err(format!("{label} draws the source art for this object instead of its 3D surface"));
|
||||
}
|
||||
let n = &*resolve(doc, n, label)?;
|
||||
let faces = if revolve { crate::revolve::revolve_face_paths(n)? } else { solid_face_paths(n)? };
|
||||
let faces = after_geometry(n, faces);
|
||||
let wire = edges(&faces)?;
|
||||
if wire.is_empty() {
|
||||
return Err(format!("{label} draws no faces for this object"));
|
||||
}
|
||||
Ok(wire)
|
||||
}
|
||||
|
||||
/// `faces` reshaped by the visible geometry effects after `n`'s own 3D effect, which act on the
|
||||
/// projected art as a whole, about the bounds of all the faces.
|
||||
fn after_geometry(n: &Node, faces: Vec<PathData>) -> Vec<PathData> {
|
||||
let fx = &n.appearance.effects;
|
||||
let Some(index) = fx.iter().position(|e| e.visible && is_three_d(&e.id)) else { return faces };
|
||||
reshaped(faces, fx.get(index + 1..).unwrap_or_default(), &GeomContext::of(n))
|
||||
}
|
||||
|
||||
/// `faces` reshaped by the visible geometry effects in `later` (the effects after a 3D effect),
|
||||
/// which act on the faces as a whole, about their bounds, as they act on the drawn surface.
|
||||
pub(crate) fn reshaped(faces: Vec<PathData>, later: &[Effect], ctx: &GeomContext) -> Vec<PathData> {
|
||||
let after: Vec<Effect> = later.iter().filter(|e| e.visible && is_geometry(&e.id)).cloned().collect();
|
||||
if after.is_empty() {
|
||||
return faces;
|
||||
}
|
||||
let joined = PathData::new(faces.into_iter().flat_map(|f| f.subpaths).collect());
|
||||
let Some(bounds) = joined.bounds() else { return vec![joined] };
|
||||
vec![apply_geometry_with(&after, &joined, bounds, ctx)]
|
||||
}
|
||||
|
||||
/// The outline of every face [`super::solid_art`] draws for `n` (already resolved): with the effect
|
||||
/// on the object, its solids; with it on fills and strokes, the solid of each; on a group or layer,
|
||||
/// the solids of its members, about one shared axis.
|
||||
fn solid_face_paths(n: &Node) -> Result<Vec<PathData>, String> {
|
||||
if is_container(n) {
|
||||
let (index, fx) = solid(&n.appearance.effects).ok_or("a 3D effect on a group's fill or stroke draws nothing")?;
|
||||
let mut shapes = members(n, label_of(&fx.id))?;
|
||||
reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes);
|
||||
return facet_paths(fx, &shapes);
|
||||
}
|
||||
let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id));
|
||||
let (path, rule) = object_profile(n, label)?;
|
||||
let ctx = GeomContext::of(n);
|
||||
let paints = solid_paints(n);
|
||||
if let Some((index, fx)) = solid(&n.appearance.effects) {
|
||||
let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx);
|
||||
return facet_paths(fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }]);
|
||||
}
|
||||
let path = geometry(&path, &n.appearance.effects, &ctx);
|
||||
let mut out = vec![];
|
||||
for item in &paints {
|
||||
if let Some((index, fx)) = solid(item.effects()) {
|
||||
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item));
|
||||
let faces = facet_paths(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?;
|
||||
out.extend(reshaped(faces, item.effects().get(index + 1..).unwrap_or_default(), &ctx));
|
||||
}
|
||||
}
|
||||
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
|
||||
/// 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> {
|
||||
let label = label_of(&fx.id);
|
||||
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 {
|
||||
for s in shapes {
|
||||
for item in &s.items {
|
||||
let (area, rule) = region(&s.path, s.rule, item);
|
||||
out.extend(facets(ROTATE, &area, rule, b, &p)?.iter().map(Facet::path));
|
||||
}
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
let (parts, bounds) = solid_parts(shapes, b);
|
||||
let built: Vec<Result<Vec<Facet>, String>> = if fx.id == INFLATE {
|
||||
let shapes: Vec<(&PathData, vectorcraft_geom::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()
|
||||
};
|
||||
for ((item, ..), f) in parts.iter().zip(built) {
|
||||
match f {
|
||||
Ok(f) => out.extend(f.iter().map(Facet::path)),
|
||||
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A point on the [`GRID`].
|
||||
type Snap = (i64, i64);
|
||||
|
||||
fn snap(p: Point) -> Snap {
|
||||
((p.x / GRID).round() as i64, (p.y / GRID).round() as i64)
|
||||
}
|
||||
|
||||
fn reversed(c: CubicBez) -> CubicBez {
|
||||
CubicBez::new(c.p3, c.p2, c.p1, c.p0)
|
||||
}
|
||||
|
||||
/// The edges of `faces`, each once, joined end to end into paths ([`three_d_wireframe_in`]).
|
||||
pub(crate) fn edges(faces: &[PathData]) -> Result<Vec<PathData>, String> {
|
||||
let mut seen: HashSet<[Snap; 4]> = HashSet::new();
|
||||
let mut segments: Vec<CubicBez> = vec![];
|
||||
for sp in faces.iter().flat_map(|f| &f.subpaths) {
|
||||
for i in 0..sp.segment_count() {
|
||||
let mut c = sp.segment(i);
|
||||
if sp.segment_is_line(i) {
|
||||
c = CubicBez::new(c.p0, c.p0, c.p3, c.p3);
|
||||
}
|
||||
if [c.p0, c.p1, c.p2, c.p3].iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
let key = [c.p0, c.p1, c.p2, c.p3].map(snap);
|
||||
// A segment that starts and ends on one grid point with its handles there too draws nothing.
|
||||
if key.iter().all(|k| *k == key[0]) {
|
||||
continue;
|
||||
}
|
||||
let back = [key[3], key[2], key[1], key[0]];
|
||||
if seen.contains(&back) || !seen.insert(key) {
|
||||
continue;
|
||||
}
|
||||
if segments.len() >= MAX_WIRE_EDGES {
|
||||
return Err(format!("the wireframe has more than {MAX_WIRE_EDGES} edges"));
|
||||
}
|
||||
segments.push(c);
|
||||
}
|
||||
}
|
||||
Ok(chain(&segments))
|
||||
}
|
||||
|
||||
/// `segments` joined end to end: each path starts at the first segment not yet used and takes in
|
||||
/// the unused segments that meet its ends until none is left at either end, or it closes.
|
||||
fn chain(segments: &[CubicBez]) -> Vec<PathData> {
|
||||
let mut at: HashMap<Snap, Vec<usize>> = HashMap::new();
|
||||
for (i, c) in segments.iter().enumerate() {
|
||||
at.entry(snap(c.p0)).or_default().push(i);
|
||||
if snap(c.p3) != snap(c.p0) {
|
||||
at.entry(snap(c.p3)).or_default().push(i);
|
||||
}
|
||||
}
|
||||
let mut used = vec![false; segments.len()];
|
||||
// The next unused segment at `point`, turned to leave it (`leave`) or to arrive at it.
|
||||
let mut take = |used: &mut Vec<bool>, point: Snap, leave: bool| -> Option<CubicBez> {
|
||||
let list = at.get_mut(&point)?;
|
||||
while let Some(&i) = list.last() {
|
||||
if used.get(i).copied().unwrap_or(true) {
|
||||
list.pop();
|
||||
continue;
|
||||
}
|
||||
if let Some(u) = used.get_mut(i) {
|
||||
*u = true;
|
||||
}
|
||||
let c = *segments.get(i)?;
|
||||
let starts = snap(c.p0) == point;
|
||||
return Some(if starts == leave { c } else { reversed(c) });
|
||||
}
|
||||
None
|
||||
};
|
||||
let mut out = vec![];
|
||||
for (i, first) in segments.iter().enumerate() {
|
||||
if used.get(i).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
if let Some(u) = used.get_mut(i) {
|
||||
*u = true;
|
||||
}
|
||||
let mut run: VecDeque<CubicBez> = VecDeque::from([*first]);
|
||||
// A path of one anchor has no segment (`SubPath::segment_count`), so a run of one segment
|
||||
// that comes back to its start stays open, with its two ends on one point.
|
||||
let closes = |run: &VecDeque<CubicBez>| run.len() > 1 && run.front().zip(run.back()).is_some_and(|(f, b)| snap(f.p0) == snap(b.p3));
|
||||
while !closes(&run) {
|
||||
let Some(end) = run.back().map(|c| snap(c.p3)) else { break };
|
||||
let Some(c) = take(&mut used, end, true) else { break };
|
||||
run.push_back(c);
|
||||
}
|
||||
while !closes(&run) {
|
||||
let Some(start) = run.front().map(|c| snap(c.p0)) else { break };
|
||||
let Some(c) = take(&mut used, start, false) else { break };
|
||||
run.push_front(c);
|
||||
}
|
||||
out.push(PathData::single(subpath(&run, closes(&run))));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `run`, segments that meet end to end, as one subpath; a closed one ends at its first anchor.
|
||||
fn subpath(run: &VecDeque<CubicBez>, closed: bool) -> SubPath {
|
||||
let mut anchors: Vec<Anchor> = vec![];
|
||||
if let Some(first) = run.front() {
|
||||
let h_in = if closed { run.back().map_or(first.p0, |b| b.p2) } else { first.p0 };
|
||||
anchors.push(Anchor::with_handles(first.p0, h_in, first.p1));
|
||||
}
|
||||
for (k, c) in run.iter().enumerate() {
|
||||
let h_out = run.get(k + 1).map_or(c.p3, |next| next.p1);
|
||||
anchors.push(Anchor::with_handles(c.p3, c.p2, h_out));
|
||||
}
|
||||
// A closed run's last anchor is its first, whose in handle already comes from the last segment.
|
||||
if closed {
|
||||
anchors.pop();
|
||||
}
|
||||
SubPath::new(anchors, closed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,156 @@
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Node, NodeId};
|
||||
use vectorcraft_geom::{BezPath, Rect};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn square(x: f64, y: f64, size: f64) -> PathData {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((x, y));
|
||||
b.line_to((x + size, y));
|
||||
b.line_to((x + size, y + size));
|
||||
b.line_to((x, y + size));
|
||||
b.close_path();
|
||||
PathData::from_bezpath(&b)
|
||||
}
|
||||
|
||||
/// A filled 100 pt square without a stroke, with effect `id` and `params`.
|
||||
fn solid_square(id: &str, params: Value) -> Node {
|
||||
let mut n = Node::path(NodeId(1), square(100.0, 100.0, 100.0), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||||
n.appearance.effects.push(crate::new_effect(id, ¶ms).unwrap());
|
||||
n
|
||||
}
|
||||
|
||||
/// Every segment of `wire`, each as its two ends on the grid.
|
||||
fn segments(wire: &[PathData]) -> Vec<(Snap, Snap)> {
|
||||
wire.iter()
|
||||
.flat_map(|p| &p.subpaths)
|
||||
.flat_map(|sp| (0..sp.segment_count()).map(move |i| sp.segment(i)))
|
||||
.map(|c| (snap(c.p0), snap(c.p3)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn bounds(paths: &[PathData]) -> Rect {
|
||||
paths.iter().filter_map(PathData::bounds).reduce(|a, b| a.union(b)).unwrap()
|
||||
}
|
||||
|
||||
/// The paths of the faces `art` draws (every leaf of the art).
|
||||
fn face_paths(art: &Node) -> Vec<PathData> {
|
||||
let mut out = vec![];
|
||||
art.walk(&mut |c| out.extend(c.path_data().cloned()));
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_faces_that_share_an_edge_give_it_once() {
|
||||
let wire = edges(&[square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)]).unwrap();
|
||||
let segs = segments(&wire);
|
||||
assert_eq!(segs.len(), 7, "{segs:?}");
|
||||
for (i, a) in segs.iter().enumerate() {
|
||||
for b in segs.iter().skip(i + 1) {
|
||||
assert!(a != b && (a.1, a.0) != *b, "{a:?} twice");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loop_of_edges_is_one_closed_path_and_a_curve_stays_a_curve() {
|
||||
let wire = edges(&[square(0.0, 0.0, 10.0)]).unwrap();
|
||||
assert_eq!(wire.len(), 1);
|
||||
assert!(wire[0].subpaths[0].closed);
|
||||
assert_eq!(wire[0].subpaths[0].anchors.len(), 4);
|
||||
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((0.0, 0.0));
|
||||
b.curve_to((0.0, 10.0), (10.0, 10.0), (10.0, 0.0));
|
||||
b.close_path();
|
||||
let wire = edges(&[PathData::from_bezpath(&b)]).unwrap();
|
||||
let curved = wire.iter().flat_map(|p| &p.subpaths).any(|sp| (0..sp.segment_count()).any(|i| !sp.segment_is_line(i)));
|
||||
assert!(curved, "{wire:?}");
|
||||
}
|
||||
|
||||
/// A single curve that comes back to its start keeps its segment: it stays open, with two anchors on
|
||||
/// one point, since a path of one anchor has no segment.
|
||||
#[test]
|
||||
fn a_single_curve_that_comes_back_to_its_start_keeps_its_segment() {
|
||||
let p = Point::new(0.0, 0.0);
|
||||
let anchors = vec![Anchor::with_handles(p, p, Point::new(0.0, 10.0)), Anchor::with_handles(p, Point::new(10.0, 10.0), p)];
|
||||
let face = PathData::single(SubPath::new(anchors, false));
|
||||
assert_eq!(face.subpaths[0].segment_count(), 1);
|
||||
let wire = edges(&[face]).unwrap();
|
||||
assert_eq!(wire.len(), 1);
|
||||
let sp = &wire[0].subpaths[0];
|
||||
assert_eq!(sp.segment_count(), 1, "{sp:?}");
|
||||
assert!(!sp.segment_is_line(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edges_without_length_or_with_non_finite_points_are_left_out() {
|
||||
let point = PathData::single(SubPath::polyline(&[Point::new(5.0, 5.0), Point::new(5.0, 5.0)], false));
|
||||
let bad = PathData::single(SubPath::polyline(&[Point::new(f64::NAN, 0.0), Point::new(1.0, 1.0)], false));
|
||||
assert!(edges(&[point, bad]).unwrap().is_empty());
|
||||
assert!(edges(&[]).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_turned_off_axis_shows_the_nine_edges_of_its_three_faces() {
|
||||
let n = solid_square(super::super::EXTRUDE, json!({}));
|
||||
let wire = three_d_wireframe_in(None, &n).unwrap();
|
||||
assert_eq!(segments(&wire).len(), 9, "{wire:?}");
|
||||
// The wireframe covers what the effect draws, in the same place.
|
||||
let art = super::super::solid_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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_classic_wireframe_surface_expands_to_all_twelve_edges() {
|
||||
let n = solid_square(super::super::CLASSIC_EXTRUDE, json!({"surface": "wireframe"}));
|
||||
assert_eq!(segments(&three_d_wireframe_in(None, &n).unwrap()).len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_inflate_and_rotate_expand_too() {
|
||||
for id in [crate::REVOLVE, super::super::INFLATE, ROTATE] {
|
||||
let wire = three_d_wireframe_in(None, &solid_square(id, json!({}))).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
assert!(!wire.is_empty(), "{id}");
|
||||
assert!(wire.iter().flat_map(|p| &p.subpaths).flat_map(|sp| &sp.anchors).all(|a| a.p.x.is_finite() && a.p.y.is_finite()), "{id}");
|
||||
}
|
||||
// Rotate is one flat face: its outline.
|
||||
let plane = three_d_wireframe_in(None, &solid_square(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() {
|
||||
for id in [super::super::EXTRUDE, crate::REVOLVE] {
|
||||
let mut n = solid_square(id, 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_or_else(|e| panic!("{id}: {e}")));
|
||||
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, "{id}: {plain:?} {moved:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_without_a_visible_3d_effect_is_an_error() {
|
||||
let mut n = solid_square(super::super::EXTRUDE, json!({}));
|
||||
n.appearance.effects[0].visible = false;
|
||||
assert!(three_d_wireframe_in(None, &n).unwrap_err().contains("no 3D effect"));
|
||||
n.appearance.effects.clear();
|
||||
assert!(three_d_wireframe_in(None, &n).is_err());
|
||||
}
|
||||
@@ -105,6 +105,7 @@ mod variablecmds;
|
||||
pub(crate) mod views;
|
||||
pub mod wand;
|
||||
pub mod webexport;
|
||||
mod wireframe;
|
||||
mod xform;
|
||||
|
||||
use serde::Serialize;
|
||||
@@ -308,6 +309,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(draw2::specs());
|
||||
v.extend(xform::specs());
|
||||
v.extend(effectcmd::specs());
|
||||
v.extend(wireframe::specs());
|
||||
v.extend(pathops::specs());
|
||||
v.extend(compoundshape::specs());
|
||||
v.extend(typecmd::specs());
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Window › 3D and Materials › Quick Actions › Expand as Wireframes: each object with a 3D effect
|
||||
//! becomes a group of stroked paths, the edges of the faces its effect draws.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::appearance::AppearanceItem;
|
||||
use vectorcraft_doc::{Appearance, LineJoin, Node, NodeId, NodeKind, StrokeLayer};
|
||||
use vectorcraft_render::effects;
|
||||
|
||||
use super::{CommandSpec, Result, Session, bad, checked_ids_or_selection, cmd, has_doc};
|
||||
|
||||
const C: &str = "effect.expandAsWireframes";
|
||||
|
||||
/// The name of the undo step.
|
||||
const LABEL: &str = "Expand as Wireframes";
|
||||
|
||||
/// The weight of a wireframe edge's stroke, in points (VectorCraft's own).
|
||||
pub const WIRE_WEIGHT: f64 = 0.5;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"effect.expandAsWireframes",
|
||||
"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}",
|
||||
has_doc,
|
||||
expand_as_wireframes
|
||||
)]
|
||||
}
|
||||
|
||||
/// A wireframe edge's appearance: a 0.5 pt black stroke with round joins and no fill.
|
||||
fn wire_appearance() -> Appearance {
|
||||
let mut stroke = StrokeLayer::new(Paint::solid(Color::BLACK), WIRE_WEIGHT);
|
||||
stroke.join = LineJoin::Round;
|
||||
Appearance { items: vec![AppearanceItem::Stroke(stroke)], ..Default::default() }
|
||||
}
|
||||
|
||||
fn expand_as_wireframes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = checked_ids_or_selection(s, p, C)?;
|
||||
if roots.is_empty() {
|
||||
return Err(bad(C, "select an object with a 3D effect, or give its id in ids"));
|
||||
}
|
||||
// Every wireframe is built before the edit, so a failure leaves the document and its history as
|
||||
// they are.
|
||||
let d = &s.doc()?.doc;
|
||||
let mut wires = vec![];
|
||||
for id in roots {
|
||||
let Some(n) = d.node(id) else { continue };
|
||||
if !effects::has_revolve(n) && !effects::has_solid(n) {
|
||||
continue;
|
||||
}
|
||||
let wire = effects::three_d_wireframe_in(Some(d), n).map_err(|e| bad(C, format!("object {}: {e}", id.0)))?;
|
||||
wires.push((id, wire));
|
||||
}
|
||||
if wires.is_empty() {
|
||||
return Err(bad(C, "no selected or given object has a visible 3D effect"));
|
||||
}
|
||||
let ids: Vec<NodeId> = s.edit(LABEL, |d, sel| {
|
||||
let mut out = vec![];
|
||||
let mut selected = vec![];
|
||||
for (id, wire) in wires {
|
||||
let Some(source) = d.node(id) else { continue };
|
||||
if matches!(source.kind, NodeKind::Layer { .. }) {
|
||||
// A layer stays a layer: its members become the edges, and its 3D effect is removed.
|
||||
let mut layer = source.clone();
|
||||
let children: Vec<Arc<Node>> = wire.into_iter().map(|path| Arc::new(Node::path(d.alloc_id(), path, wire_appearance()))).collect();
|
||||
selected.extend(children.iter().map(|c| c.id));
|
||||
if let Some(members) = layer.children_mut() {
|
||||
*members = children;
|
||||
}
|
||||
layer.appearance = Default::default();
|
||||
if let Some(slot) = d.node_mut(id) {
|
||||
*slot = layer;
|
||||
out.push(id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// The group keeps the object's name, visibility, lock, bounding box angle, transparency
|
||||
// (opacity, blending mode, isolation, knockout and opacity mask), attributes, slice and text
|
||||
// wrap.
|
||||
let mut group = Node::group(id, vec![]);
|
||||
group.name = source.name.clone();
|
||||
group.visible = source.visible;
|
||||
group.locked = source.locked;
|
||||
group.opacity = source.opacity;
|
||||
group.blend = source.blend;
|
||||
group.isolate = source.isolate;
|
||||
group.knockout = source.knockout;
|
||||
group.knockout_shape = source.knockout_shape;
|
||||
group.bbox_angle = source.bbox_angle;
|
||||
let extras = source.extras();
|
||||
let (mask, attrs, slice, wrap) = (extras.mask.clone(), extras.attrs.clone(), extras.slice.clone(), extras.wrap);
|
||||
if mask.is_some() || attrs.is_some() || slice.is_some() || wrap.is_some() {
|
||||
let mut e = group.extras_mut();
|
||||
e.mask = mask;
|
||||
e.attrs = attrs;
|
||||
e.slice = slice;
|
||||
e.wrap = wrap;
|
||||
}
|
||||
let children = wire.into_iter().map(|path| Arc::new(Node::path(d.alloc_id(), path, wire_appearance()))).collect();
|
||||
group.kind = NodeKind::Group { children, clip: false };
|
||||
if let Some(slot) = d.node_mut(id) {
|
||||
*slot = group;
|
||||
out.push(id);
|
||||
selected.push(id);
|
||||
}
|
||||
}
|
||||
sel.set(selected);
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({ "ids": ids.iter().map(|id| id.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,143 @@
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::appearance::AppearanceItem;
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A new document with a filled 100 pt square; `effect` is applied to it when given.
|
||||
fn setup(effect: Option<&str>) -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":400,"height":400})).unwrap();
|
||||
let id = NodeId(s.execute("path.create", &json!({"d":"M100 100L200 100L200 200L100 200Z"})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("paint.setFill", &json!({"color":"#338ad6"})).unwrap();
|
||||
if let Some(effect) = effect {
|
||||
s.execute("effect.apply", &json!({"effect":effect,"item":null,"params":{}})).unwrap();
|
||||
}
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, p: Value) -> crate::Result<Value> {
|
||||
s.execute("effect.expandAsWireframes", &p)
|
||||
}
|
||||
|
||||
#[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"] {
|
||||
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();
|
||||
let history = undo_len(&s);
|
||||
let out = run(&mut s, json!({})).unwrap_or_else(|e| panic!("{effect}: {e}"));
|
||||
assert_eq!(out["ids"], json!([id.0]), "{effect}");
|
||||
let st = s.doc().unwrap();
|
||||
let group = st.doc.node(id).unwrap();
|
||||
let NodeKind::Group { children, clip: false } = &group.kind else { panic!("{effect}: a group") };
|
||||
assert!(!children.is_empty(), "{effect}");
|
||||
assert_eq!(group.name.as_deref(), Some("Box"), "{effect}");
|
||||
assert!(group.appearance.effects.is_empty(), "{effect}: the effect is gone");
|
||||
for c in children {
|
||||
assert!(c.path_data().is_some(), "{effect}: paths only");
|
||||
let [AppearanceItem::Stroke(stroke)] = c.appearance.items.as_slice() else { panic!("{effect}: one stroke and no fill") };
|
||||
assert_eq!(stroke.width, super::WIRE_WEIGHT);
|
||||
}
|
||||
assert_eq!(st.selection.objects, vec![id], "{effect}");
|
||||
assert_eq!(undo_len(&s), history + 1, "{effect}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc, before, "{effect}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_group_keeps_the_object_s_transparency() {
|
||||
let (mut s, id) = setup(Some("threeD.extrude"));
|
||||
s.execute("transparency.set", &json!({"opacity": 50, "knockout": "on"})).unwrap();
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().bbox_angle = 0.25;
|
||||
let before = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
run(&mut s, json!({})).unwrap();
|
||||
let group = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
assert!((group.opacity - 0.5).abs() < 1e-6, "{}", group.opacity);
|
||||
assert_eq!((group.opacity, group.blend, group.knockout), (before.opacity, before.blend, before.knockout));
|
||||
assert_eq!(group.bbox_angle, 0.25, "the bounding box keeps its angle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_with_a_3d_effect_stays_a_layer_holding_the_edges() {
|
||||
let (mut s, _) = setup(None);
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
s.execute("effect.apply", &json!({"ids":[layer.0],"effect":"threeD.extrude","item":null,"params":{}})).unwrap();
|
||||
let history = undo_len(&s);
|
||||
let out = run(&mut s, json!({"ids":[layer.0]})).unwrap();
|
||||
assert_eq!(out["ids"], json!([layer.0]));
|
||||
let st = s.doc().unwrap();
|
||||
let node = st.doc.node(layer).unwrap();
|
||||
assert!(matches!(node.kind, NodeKind::Layer { .. }));
|
||||
assert!(node.appearance.effects.is_empty());
|
||||
let children = node.children().unwrap();
|
||||
assert!(!children.is_empty() && children.iter().all(|c| c.path_data().is_some()));
|
||||
assert_eq!(st.selection.objects, children.iter().map(|c| c.id).collect::<Vec<_>>());
|
||||
assert_eq!(undo_len(&s), history + 1);
|
||||
}
|
||||
|
||||
/// The number of segments in the wireframe group `id`.
|
||||
fn segments(s: &Session, id: NodeId) -> usize {
|
||||
let st = s.doc().unwrap();
|
||||
st.doc.node(id).unwrap().children().unwrap().iter().flat_map(|c| &c.path_data().unwrap().subpaths).map(|sp| sp.segment_count()).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_expands_to_the_nine_edges_of_its_three_faces() {
|
||||
let (mut s, id) = setup(Some("threeD.extrude"));
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
run(&mut s, json!({"ids":[id.0]})).unwrap();
|
||||
assert_eq!(segments(&s, id), 9);
|
||||
// A stroke is outlined before the effect, so its own solid adds its edges.
|
||||
let (mut s, id) = setup(Some("threeD.extrude"));
|
||||
run(&mut s, json!({"ids":[id.0]})).unwrap();
|
||||
assert!(segments(&s, id) > 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_without_a_3d_effect_are_left_and_none_at_all_is_an_error() {
|
||||
let (mut s, plain) = setup(None);
|
||||
let history = undo_len(&s);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let e = run(&mut s, json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("no selected or given object has a visible 3D effect"), "{e}");
|
||||
assert_eq!(undo_len(&s), history, "no undo step when nothing changes");
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
|
||||
// A hidden 3D effect draws nothing to expand.
|
||||
s.execute("effect.apply", &json!({"effect":"threeD.extrude","item":null,"params":{}})).unwrap();
|
||||
s.execute("effect.setParams", &json!({"index":0,"item":null,"visible":false})).unwrap();
|
||||
assert!(run(&mut s, json!({})).is_err());
|
||||
|
||||
// With nothing selected.
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let e = run(&mut s, json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("select an object with a 3D effect"), "{e}");
|
||||
|
||||
// A second object with a 3D effect expands; the plain one is left as it is.
|
||||
let other = NodeId(s.execute("path.create", &json!({"d":"M250 250L300 250L300 300Z"})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("effect.remove", &json!({"ids":[plain.0],"index":0,"item":null})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids":[other.0],"color":"#ff0000"})).unwrap();
|
||||
s.execute("effect.apply", &json!({"ids":[other.0],"effect":"threeD.extrude","item":null,"params":{}})).unwrap();
|
||||
let kept = s.doc().unwrap().doc.node(plain).unwrap().clone();
|
||||
let out = run(&mut s, json!({"ids":[plain.0, other.0]})).unwrap();
|
||||
assert_eq!(out["ids"], json!([other.0]));
|
||||
assert_eq!(s.doc().unwrap().doc.node(plain).unwrap(), &kept);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_ids_are_errors() {
|
||||
let (mut s, _) = setup(Some("threeD.extrude"));
|
||||
for p in [json!({"ids":[9999]}), json!({"ids":"x"}), json!({"ids":[-1]})] {
|
||||
let history = undo_len(&s);
|
||||
assert!(run(&mut s, p.clone()).is_err(), "{p}");
|
||||
assert_eq!(undo_len(&s), history, "{p}");
|
||||
}
|
||||
}
|
||||
@@ -401,6 +401,32 @@ fn a_mixed_revolve_cap_shows_a_dash() {
|
||||
assert_eq!(undo_len(&app), history);
|
||||
}
|
||||
|
||||
/// Quick Actions › Expand as Wireframes shows for every object type, and turns the selected 3D
|
||||
/// object into a group of stroked edges in one undo step.
|
||||
#[test]
|
||||
fn expand_as_wireframes_replaces_the_object_with_its_edges() {
|
||||
for kind in ["extrude", "inflate", "revolve", "plane"] {
|
||||
let mut app = app();
|
||||
set_type(&mut app, kind).unwrap();
|
||||
let text = painted_text(&mut app, show);
|
||||
assert!(text.contains("Quick Actions") && text.contains("Expand as Wireframes"), "{kind}: {text}");
|
||||
let id = app.session.active().unwrap().selection.objects[0];
|
||||
let history = undo_len(&app);
|
||||
super::expand_as_wireframes(&mut app).unwrap();
|
||||
assert_eq!(undo_len(&app), history + 1, "{kind}");
|
||||
let st = app.session.active().unwrap();
|
||||
assert!(matches!(st.doc.node(id).unwrap().kind, vectorcraft_doc::NodeKind::Group { .. }), "{kind}");
|
||||
assert_eq!(st.selection.objects, vec![id], "{kind}");
|
||||
// The group has no 3D effect: the panel offers the object types again.
|
||||
assert!(target(&app).is_none(), "{kind}");
|
||||
}
|
||||
// Without a 3D effect in the selection, nothing changes.
|
||||
let mut app = app();
|
||||
let history = undo_len(&app);
|
||||
assert!(super::expand_as_wireframes(&mut app).is_err());
|
||||
assert_eq!(undo_len(&app), history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_settings_lists_only_new_values() {
|
||||
let before = json!({"depth": 50.0, "cap": true, "bevel": "none"});
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
use egui::Ui;
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{Effect, Node};
|
||||
use vectorcraft_effects::{THREE_D_TYPES, is_three_d};
|
||||
use vectorcraft_effects::{CLASSIC_EXTRUDE, EXTRUDE, INFLATE, THREE_D_TYPES, is_three_d};
|
||||
|
||||
use super::{live_run, pstate, set_pstate};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, Live, dim_label};
|
||||
|
||||
/// The panel's id (`window.panel`).
|
||||
@@ -242,6 +243,32 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let changed = changed_settings(&before, &after);
|
||||
let dragging = ctx.input(|i| i.pointer.any_down());
|
||||
send(app, &ctx, changed, dragging);
|
||||
if quick_actions_shown(&d) {
|
||||
ui.add_space(10.0);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(egui::RichText::new(tl!("Quick Actions")).font(crate::theme::semibold(13.0)).color(t.text));
|
||||
let tip = tl!("Replace the selected 3D objects with the edges of their faces, as stroked paths.");
|
||||
if ui.button(tl!("Expand as Wireframes")).on_hover_text(tip).clicked() {
|
||||
finish(app, &ctx);
|
||||
if let Err(e) = expand_as_wireframes(app) {
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Does the panel show its Quick Actions? On the Object tab of the effects that have one, and on
|
||||
/// the first tab of the others (Revolve, Rotate), never on Lighting.
|
||||
fn quick_actions_shown(d: &Dialog) -> bool {
|
||||
let effect = d.str("__effect");
|
||||
let object_tab = [EXTRUDE, INFLATE, CLASSIC_EXTRUDE].contains(&effect.as_str());
|
||||
!crate::dialogs::revolve_gizmo::lighting(d) && (!object_tab || d.str("__tab") != "rotation")
|
||||
}
|
||||
|
||||
/// Quick Actions › Expand as Wireframes: each selected object with a 3D effect becomes a group of
|
||||
/// the edges of its faces (`effect.expandAsWireframes`), one undo step.
|
||||
pub(crate) fn expand_as_wireframes(app: &mut VectorcraftApp) -> Result<(), String> {
|
||||
app.run("effect.expandAsWireframes", json!({})).map(|_| ())
|
||||
}
|
||||
|
||||
/// Sets to null each setting in `fields` (the first selected object's 3D effect) that the 3D effect
|
||||
|
||||
@@ -180,7 +180,7 @@ VectorCraft choices; an original approximation, never compared with another appl
|
||||
|---|---|
|
||||
| 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, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) |
|
||||
| Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect ([three-d.md](three-d.md#3d-and-materials-panel)); 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; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); no materials, soft shadows or ray tracing |
|
||||
| Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect, and Quick Actions › Expand as Wireframes ([three-d.md](three-d.md#3d-and-materials-panel)); 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; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); no materials, soft shadows or ray tracing |
|
||||
| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic), Rotate (Classic) and Map Art: none |
|
||||
| Turntable (30.0 beta) | none |
|
||||
| Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object collects an object with Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, and Export writes its solids as a USDA (the default), USDZ, glTF or OBJ mesh with a material in each solid's paint color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`); no textures, map art or shading ([3D export help](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)) |
|
||||
|
||||
+22
@@ -3094,6 +3094,28 @@ checked as that effect's. It is one undo step. Any other `threeDType` value, or
|
||||
{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"threeDType":"inflate","params":{"volume":70}}}}
|
||||
```
|
||||
|
||||
## 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
|
||||
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,
|
||||
on the object or on a fill or stroke, reshape its edges as they reshape the drawn surface. The paths have a 0.5 pt
|
||||
black stroke with round joins and no fill (VectorCraft's own choice). The group keeps the object's place, id, name,
|
||||
visibility, lock, opacity, blending mode, knockout, opacity mask, attributes, slice and text wrap, and the groups
|
||||
are selected.
|
||||
A layer in `ids` stays a layer: its members are replaced by the edges, which are selected. Objects without a visible 3D effect do not change. It is one undo step and
|
||||
returns `{ids}`, the groups. It is an error, and nothing changes, when nothing is selected or given, when no object
|
||||
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.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"effect.expandAsWireframes","params":{}}}
|
||||
{"name":"run_command","arguments":{"command":"effect.expandAsWireframes","params":{"ids":[12]}}}
|
||||
```
|
||||
|
||||
## Extrude & Bevel, Inflate and Rotate
|
||||
|
||||
`effect.apply {effect: "threeD.extrude" | "threeD.inflate" | "threeD.rotate", params: {...}, ids?, item?}` applies
|
||||
|
||||
@@ -560,6 +560,7 @@ since 2026-10-01.
|
||||
- 3D lighting presets: `crates/effects/src/lighting_presets.rs`, `crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs` and the pointer tests in `crates/ui-egui/src/dialogs/revolve/tests.rs` cover recognition, atomic targeting, native round trips, preview, Cancel, reset and Undo.
|
||||
- 3D shadows: Revolve, Extrude & Bevel, Inflate and Rotate take a Shadows switch with Position (Behind Object or Below Object), Distance from Object (0–100 %) and Shadow Bounds (10–400 %) on the Lighting tab ([three-d.md](three-d.md#shadows)); the first light casts one hard-edged vector shadow, drawn behind the solids, in exports and kept by Expand Appearance. Evidence: `crates/three-d/src/shadow/tests.rs`, `crates/effects/src/solids/tests_shadow.rs`, `effectcmd::tests::a_3d_shadow_is_one_undo_step_saved_exported_and_expanded`. Softness and the shadows of lights after the first are not built. Scores unchanged.
|
||||
- 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.
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -51,6 +51,19 @@ pages describe it.
|
||||
unavailable while a setting of a single light differs between the selected effects.
|
||||
- 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
|
||||
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
|
||||
end form one path, closed where it comes back to its start, and curved edges stay curves. A face partly covered
|
||||
by another face gives its whole outline. Faces turned away from the viewer are included where the effect draws
|
||||
them (Draw Hidden Faces and the Wireframe surface of Extrude & Bevel (Classic)). Geometry effects after a 3D
|
||||
effect, on the object or on a fill or stroke, reshape the edges as they reshape the drawn art. Shadows are not
|
||||
part of the wireframe. The paths have a 0.5 pt black stroke with round joins and no fill, which is VectorCraft's
|
||||
own choice. The group keeps the object's place, id, name, opacity, blending mode, opacity mask, attributes
|
||||
and text wrap and is selected; objects without a visible 3D effect do not change. It is one undo step, and an error changes nothing
|
||||
when no selected object has a visible 3D effect.
|
||||
- The Lighting tab also holds the Shadows switch and its settings ([Shadows](#shadows)). The Materials tab and
|
||||
ray-traced rendering are not built (see below). The rotation and light rings on the artboard are part of the
|
||||
options dialog only.
|
||||
|
||||
Reference in New Issue
Block a user