From 4416640bf4a326dcd4e533dfc76a05c4656b30da Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 20:43:34 -0800 Subject: [PATCH] 3D: Expand as Wireframes outlines each face's own stroke under a later Outline Stroke Expand as Wireframes reshaped the faces of a 3D effect with the geometry effects after it, but the faces it built carried no stroke. An Outline Stroke after the 3D effect then outlined a plain 1 pt stroke with miter joins, while the canvas outlines the round-joined stroke each face is drawn with: the seam stroke (half the facet's thickness, 0.05 to 0.75 pt; 0.75 pt on Revolve) or the 0.5 pt wire edge of the Classic Wireframe surface. The wireframe's faces now carry that stroke, so the expanded edges lie where the drawn outlines lie. --- crates/effects/src/revolve.rs | 21 +++++--- crates/effects/src/solids.rs | 13 +++-- crates/effects/src/solids/classic.rs | 8 ++- crates/effects/src/solids/wireframe.rs | 52 ++++++++++++++------ crates/effects/src/solids/wireframe/tests.rs | 36 ++++++++++++-- 5 files changed, 99 insertions(+), 31 deletions(-) diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 74b23340cd..d1e119a807 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -8,6 +8,7 @@ use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, N use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2}; use vectorcraft_three_d::{Edge, Revolve}; +use crate::solids::wireframe::WireFace; use crate::solids::{ Profile, Scope, is_container, members, object_profile, past_effect, profile, reshape_members, resolve_for, solid_paints, union_bounds, }; @@ -635,10 +636,16 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul /// 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. The swatches and patterns named by a live-effect plug-in -/// after a fill's or stroke's Revolve are looked up in `doc`. -pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result, String> { - let faces = |path: &PathData, b: Rect, options: Revolve| -> Result, String> { - Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(vectorcraft_three_d::Face::path).collect()) +/// after a fill's or stroke's Revolve are looked up in `doc`. Each face carries the width of the +/// stroke the canvas draws around it ([`surface`]). +pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result, String> { + let faces = |path: &PathData, b: Rect, fx: &Effect, options: Revolve| -> Result, String> { + if fx.id == CLASSIC_REVOLVE { + let p = merged_params(&fx.id, &fx.params); + let facets = vectorcraft_three_d::revolve_facets(path, b, options)?; + return Ok(facets.iter().map(|f| WireFace { path: f.path(), stroke: crate::solids::classic::stroke_width(f, &p) }).collect()); + } + Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(|f| WireFace { path: f.path(), stroke: SEAM_WIDTH }).collect()) }; if is_container(n) { let index = n @@ -654,7 +661,7 @@ pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result, n: &Node) -> Result, n: &Node) -> Result, paint: &Paint, opacity: f32, blend: Ble .into_iter() .map(|face| { // Adjacent facets, antialiased on their own, would leave hairline seams: a narrow - // stroke of the same paint closes them. No wider than the facet is thick, though: seen - // at a grazing angle a facet can be thinner than the stroke, and its stroke would then - // paint over its neighbours. - let seam = (face.thickness() / 2.0).clamp(0.05, 0.75); + // stroke of the same paint closes them ([`seam_width`]). + let seam = seam_width(&face); if let Some(g) = face.rgb_gradient && (paint.color().is_some() || matches!(paint, Paint::Gradient(_))) { @@ -592,6 +590,13 @@ fn surface(n: &Node, facets: Vec, paint: &Paint, opacity: f32, blend: Ble group } +/// The width of the stroke of a facet's own paint that closes the seams between `face` and its +/// neighbors. No wider than the facet is thick: seen at a grazing angle a facet can be thinner than +/// the stroke, and its stroke would then paint over its neighbors. +pub(crate) fn seam_width(face: &Facet) -> f64 { + (face.thickness() / 2.0).clamp(0.05, 0.75) +} + /// Is a surface of `facets` in `paint` one composite of all its facets when [`surface`] draws it /// whole: a solid color under colored lights with smooth shading? fn composites_facets(facets: &[Facet], paint: &Paint, colored: bool) -> bool { diff --git a/crates/effects/src/solids/classic.rs b/crates/effects/src/solids/classic.rs index 23d8d0ffad..29c05406a2 100644 --- a/crates/effects/src/solids/classic.rs +++ b/crates/effects/src/solids/classic.rs @@ -192,6 +192,12 @@ fn shaded(c: Color, tone: f64, spec: f64, shade: Option) -> Color { Color::rgb(r, g, b) } +/// The width of the stroke [`surface`] draws around `face` with parameters `p`: a wire edge on +/// Wireframe, else the seam between faces. +pub(crate) fn stroke_width(face: &Facet, p: &Value) -> f64 { + if classic_shading(p).surface == ClassicSurface::Wireframe { WIRE } else { super::seam_width(face) } +} + /// One solid's faces in paint `paint`, as a group with the paint's opacity and blend mode, one child /// for each facet: the edges of the faces on Wireframe, else each face filled in its paint shaded by /// its tone and highlight. A gradient with transparent stops is shaded opaque and then masked by @@ -213,7 +219,7 @@ pub(crate) fn surface(n: &Node, facets: Vec, source: &Paint, opacity: f32 } let (tone, spec) = shading.tone(face.normal, &lights); // A narrow stroke of the face's own paint closes the hairline seams between faces. - let seam = (face.thickness() / 2.0).clamp(0.05, 0.75); + let seam = super::seam_width(&face); let items = match paint.color() { // Unshaded (No Shading, no shading color, or a face in full light) with no // highlight: the paint itself, its color space and swatch kept. diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs index e6e7d89ab5..1e19a21c15 100644 --- a/crates/effects/src/solids/wireframe.rs +++ b/crates/effects/src/solids/wireframe.rs @@ -4,13 +4,13 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::sync::Arc; use vectorcraft_doc::color::{Color, Paint}; -use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Node, NodeId}; use vectorcraft_geom::{Anchor, CubicBez, PathData, Point, SubPath}; use vectorcraft_three_d::Facet; use super::{ - Profile, ROTATE, built_solids, facets, geometry, has_solid, is_container, is_plane, is_three_d, label_of, members, object_profile, region, - reshape_members, resolve, resolve_for, solid, solid_paint_sources, solid_parts, union_bounds, + CLASSIC_EXTRUDE, CLASSIC_ROTATE, Profile, ROTATE, built_solids, facets, geometry, has_solid, is_container, is_plane, is_three_d, label_of, + members, object_profile, region, reshape_members, resolve, resolve_for, solid, solid_paint_sources, solid_parts, union_bounds, }; use crate::{GeomContext, is_geometry}; @@ -54,7 +54,7 @@ pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result = after_geometry(doc, n, faces, revolve).into_iter().map(|f| f.path).collect(); let wire = edges(&faces)?; if wire.is_empty() { return Err(format!("{label} draws no faces for this object")); @@ -65,29 +65,46 @@ pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result, n: &Node, faces: Vec, revolve: bool) -> Vec { +fn after_geometry(doc: Option<&Document>, n: &Node, faces: Vec, revolve: bool) -> Vec { let fx = &n.appearance.effects; let drawn = if revolve { fx.iter().position(|e| e.visible && crate::revolve::is_revolve(&e.id)) } else { solid(fx).map(|(i, _)| i) }; let Some(index) = drawn else { return faces }; reshaped(faces, fx.get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..GeomContext::of(n) }) } +/// One face of a drawn 3D surface: its outline and the width of the stroke the canvas draws around +/// it (the seam between faces, or a wire edge), which a later Outline Stroke outlines. +pub(crate) struct WireFace { + pub(crate) path: PathData, + pub(crate) stroke: f64, +} + /// `faces` reshaped by the visible geometry effects in `later` (the effects after a 3D effect), as /// they reshape the faces of the drawn surface ([`crate::reshape::reshape_leaves`]): each effect /// reshapes each face on its own, about the bounds all the faces have before it, and the faces keep -/// to one budget of anchors together. `ctx` gives the object's id, the seed of a random Transform, -/// and the document. -pub(crate) fn reshaped(faces: Vec, later: &[Effect], ctx: &GeomContext) -> Vec { +/// to one budget of anchors together. Each face is filled and carries its round-joined stroke, as on +/// the canvas, so an Outline Stroke outlines that stroke. `ctx` gives the object's id, the seed of a +/// random Transform, and the document. +pub(crate) fn reshaped(faces: Vec, later: &[Effect], ctx: &GeomContext) -> Vec { let after: Vec<&Effect> = later.iter().filter(|e| e.visible && is_geometry(&e.id)).collect(); if after.is_empty() { return faces; } let id = NodeId(ctx.seed); - let fill = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0); - let mut art = Node::group(id, faces.into_iter().map(|f| Arc::new(Node::path(id, f, fill.clone()))).collect()); + let paint = Paint::solid(Color::BLACK); + let face = |f: WireFace| { + let items = + vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(crate::lighting::seam_stroke(paint.clone(), f.stroke))]; + Arc::new(Node::path(id, f.path, Appearance { items, ..Default::default() })) + }; + let mut art = Node::group(id, faces.into_iter().map(face).collect()); crate::reshape::reshape_leaves(&mut art, &after, later, ctx.doc); let mut out = vec![]; - art.walk(&mut |c| out.extend(c.path_data().cloned())); + art.walk(&mut |c| { + if let Some(path) = c.path_data() { + out.push(WireFace { path: path.clone(), stroke: c.appearance.stroke().map_or(0.0, |s| s.width) }); + } + }); out } @@ -95,7 +112,7 @@ pub(crate) fn reshaped(faces: Vec, later: &[Effect], ctx: &GeomContext /// 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. The swatches and patterns named by a live-effect /// plug-in after a fill's or stroke's 3D effect are looked up in `doc`. -fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result, String> { +fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result, 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))?; @@ -126,17 +143,20 @@ fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result, S /// 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; past the limits of the object's solids together, the result -/// is an error, as the canvas draws the source art. -fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result, String> { +/// is an error, as the canvas draws the source art. Each facet carries the width of the stroke the +/// canvas draws around it. +fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result, 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 classic = fx.id == CLASSIC_EXTRUDE || fx.id == CLASSIC_ROTATE; + let face = |f: &Facet| WireFace { path: f.path(), stroke: if classic { super::classic::stroke_width(f, &p) } else { super::seam_width(f) } }; let mut out = vec![]; if is_plane(&fx.id) { 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)); + out.extend(facets(ROTATE, &area, rule, b, &p)?.iter().map(face)); } } return Ok(out); @@ -144,7 +164,7 @@ fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result, String> let (parts, bounds) = solid_parts(shapes, b); // The solids the art draws, each past the limits of the object's solids together as it is. for f in built_solids(&fx.id, &p, &parts, bounds, vectorcraft_three_d::MAX_OBJECT_FACES) { - out.extend(f?.iter().flatten().map(Facet::path)); + out.extend(f?.iter().flatten().map(face)); } Ok(out) } diff --git a/crates/effects/src/solids/wireframe/tests.rs b/crates/effects/src/solids/wireframe/tests.rs index 792fbdb28a..3d92d3d277 100644 --- a/crates/effects/src/solids/wireframe/tests.rs +++ b/crates/effects/src/solids/wireframe/tests.rs @@ -163,13 +163,43 @@ fn revolve_classic_and_rotate_classic_expand_too() { fn later_geometry_effects_reshape_each_face_on_its_own() { let n = solid_square(super::super::EXTRUDE, json!({})); let fx = [crate::new_effect("path.offsetPath", &json!({"offset": 1.0})).unwrap()]; - let out = reshaped(vec![square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)], &fx, &GeomContext::of(&n)); - assert_eq!(out.len(), 2, "{out:?}"); - let (a, b) = (out[0].bounds().unwrap(), out[1].bounds().unwrap()); + let faces = [square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)].map(|path| WireFace { path, stroke: 0.75 }); + let out = reshaped(faces.into(), &fx, &GeomContext::of(&n)); + assert_eq!(out.len(), 2); + let (a, b) = (out[0].path.bounds().unwrap(), out[1].path.bounds().unwrap()); assert!((a.x0 + 1.0).abs() < 1e-3 && (a.x1 - 11.0).abs() < 1e-3, "{a:?}"); assert!((b.x0 - 9.0).abs() < 1e-3 && (b.x1 - 21.0).abs() < 1e-3, "{b:?}"); } +/// An Outline Stroke after a 3D effect outlines the stroke each face is drawn with (the seam between +/// faces, or a Classic wire edge), not a plain 1 pt stroke: the wireframe covers the outlined faces +/// the canvas draws, in the same place. +#[test] +fn a_later_outline_stroke_outlines_the_stroke_each_face_is_drawn_with() { + let outline = || crate::new_effect("path.outlineStroke", &json!({})).unwrap(); + let cases = [ + (super::super::EXTRUDE, json!({})), + (super::super::CLASSIC_EXTRUDE, json!({})), + (super::super::CLASSIC_EXTRUDE, json!({"surface": "wireframe"})), + (super::super::INFLATE, json!({})), + (ROTATE, json!({})), + (crate::REVOLVE, json!({})), + (crate::CLASSIC_REVOLVE, json!({})), + (crate::CLASSIC_REVOLVE, json!({"surface": "wireframe"})), + ]; + for (id, params) in cases { + let mut n = solid_square(id, params.clone()); + n.appearance.effects.push(outline()); + let wire = three_d_wireframe_in(None, &n).unwrap_or_else(|e| panic!("{id} {params}: {e}")); + let art = if crate::revolve::is_revolve(id) { crate::revolve_art(&n) } else { super::super::solid_art(&n) }.unwrap(); + let drawn = crate::group::shape_art(None, &art, None).unwrap(); + let (w, a) = (bounds(&wire), bounds(&face_paths(&drawn))); + 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, "{id} {params}: {w:?} {a:?}"); + } + } +} + /// 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() {