mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 19:27:21 -06:00
Merge pull request #1193 from alpha-rock/codex/revolve-end-caps
Revolve: smooth vector shading and continuous end caps
This commit is contained in:
@@ -200,8 +200,8 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
REVOLVE,
|
||||
"Revolve…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Open paths create uncapped surfaces; intersecting profiles use approximate painter visibility.",
|
||||
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"expandVisibleOnly":true}),
|
||||
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.",
|
||||
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"cap":true,"expandVisibleOnly":true}),
|
||||
),
|
||||
g(
|
||||
EXTRUDE,
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, LineJoin, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{PathData, Rect};
|
||||
use vectorcraft_three_d::{Edge, Revolve};
|
||||
|
||||
@@ -38,6 +38,8 @@ pub fn revolve_options(p: &Value) -> Revolve {
|
||||
light_softness: num(p, "lightSoftness", 0.0) / 100.0,
|
||||
ambient: num(p, "ambient", 25.0) / 100.0,
|
||||
shade: flag(p, "shade", true),
|
||||
smooth_shading: true,
|
||||
cap_ends: flag(p, "cap", true),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +54,9 @@ pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> {
|
||||
if p.get("edge").is_some_and(|v| v.as_str() != Some("left") && v.as_str() != Some("right")) {
|
||||
return Err("Revolve edge must be left or right".into());
|
||||
}
|
||||
if p.get("cap").is_some_and(|v| v.as_bool().is_none()) {
|
||||
return Err("Revolve cap must be a boolean".into());
|
||||
}
|
||||
vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ())
|
||||
}
|
||||
|
||||
@@ -95,7 +100,10 @@ fn surface(
|
||||
.then(|| vectorcraft_three_d::visible_faces(&faces).ok())
|
||||
.flatten();
|
||||
let faces = visible.unwrap_or_else(|| {
|
||||
faces.into_iter().map(|f| vectorcraft_three_d::VisibleFace { path: f.path(), brightness: f.brightness, depth: f.depth }).collect()
|
||||
faces
|
||||
.into_iter()
|
||||
.map(|f| vectorcraft_three_d::VisibleFace { path: f.path(), brightness: f.brightness, depth: f.depth, gradient: f.gradient })
|
||||
.collect()
|
||||
});
|
||||
let children = faces
|
||||
.into_iter()
|
||||
@@ -104,13 +112,17 @@ fn surface(
|
||||
let mut items = vec![];
|
||||
if let Some(c) = paint.color() {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
let s = face.brightness as f32;
|
||||
fill.paint = Paint::solid(Color::rgb(r * s, g * s, b * s));
|
||||
let color = |s: f64| Color::rgb(r * s as f32, g * s as f32, b * s as f32);
|
||||
fill.paint = face.gradient.map_or_else(|| Paint::solid(color(face.brightness)), |g| shading_paint(g, color, |_| 1.0));
|
||||
items.push(AppearanceItem::Fill(fill));
|
||||
} else {
|
||||
items.push(AppearanceItem::Fill(fill));
|
||||
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
|
||||
shadow.opacity = (1.0 - face.brightness) as f32;
|
||||
if let Some(g) = face.gradient {
|
||||
shadow.paint = shading_paint(g, |_| Color::BLACK, |s| (1.0 - s) as f32);
|
||||
} else {
|
||||
shadow.opacity = (1.0 - face.brightness) as f32;
|
||||
}
|
||||
items.push(AppearanceItem::Fill(shadow));
|
||||
}
|
||||
// Independently antialiased adjacent polygons otherwise leave a hairline grid.
|
||||
@@ -121,6 +133,9 @@ fn surface(
|
||||
.flat_map(|item| {
|
||||
let AppearanceItem::Fill(fill) = &item else { return vec![item] };
|
||||
let mut edge = StrokeLayer::new(fill.paint.clone(), 0.75);
|
||||
// Acute face corners must stay within the overlap width. Miter joins
|
||||
// amplify this seam stroke into visible spikes along the silhouette.
|
||||
edge.join = LineJoin::Round;
|
||||
edge.opacity = fill.opacity;
|
||||
vec![item, AppearanceItem::Stroke(edge)]
|
||||
})
|
||||
@@ -134,6 +149,15 @@ fn surface(
|
||||
Some(group)
|
||||
}
|
||||
|
||||
fn shading_paint(g: vectorcraft_three_d::ShadeGradient, color: impl Fn(f64) -> Color, opacity: impl Fn(f64) -> f32) -> Paint {
|
||||
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops =
|
||||
g.brightness.into_iter().enumerate().map(|(i, s)| GradientStop { opacity: opacity(s), ..GradientStop::new(i as f32, color(s)) }).collect();
|
||||
let mut paint = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
||||
paint.geom = Some(GradientGeom { start: g.start, end: g.end, aspect: 1.0, focal: None });
|
||||
Paint::Gradient(Box::new(paint))
|
||||
}
|
||||
|
||||
fn paint_of(item: &AppearanceItem) -> (Paint, f32, vectorcraft_doc::color::BlendMode) {
|
||||
match item {
|
||||
AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity, f.blend),
|
||||
@@ -224,6 +248,85 @@ mod tests {
|
||||
crate::new_effect(REVOLVE, &json!({})).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn seam_strokes_stay_within_half_a_stroke_width_of_each_face() {
|
||||
use kurbo::Shape;
|
||||
for shade in [false, true] {
|
||||
for angle in [110.0, 360.0] {
|
||||
let mut n = profile_node();
|
||||
n.appearance.effects.push(
|
||||
crate::new_effect(
|
||||
REVOLVE,
|
||||
&json!({
|
||||
"offset":20,"rotationX":-18,"segments":24,"cap":true,
|
||||
"shade":shade,"angle":angle
|
||||
}),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
for art in [revolve_art(&n).unwrap(), expand_revolve_art(&n).unwrap()] {
|
||||
let mut checked = 0;
|
||||
art.walk(&mut |face| {
|
||||
let Some(path) = face.path_data() else { return };
|
||||
for item in &face.appearance.items {
|
||||
let AppearanceItem::Stroke(stroke) = item else { continue };
|
||||
let bounds = path.bounds().unwrap().inflate(stroke.width / 2.0 + 0.005, stroke.width / 2.0 + 0.005);
|
||||
let ink = crate::stroke::line_outline(&path.to_bezpath(), stroke, stroke.width, 0.001).bounding_box();
|
||||
assert!(
|
||||
bounds.contains_rect(ink),
|
||||
"seam protrudes beyond its overlap: {ink:?}, {bounds:?}, shade {shade}, sweep {angle}"
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
});
|
||||
assert!(checked > 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn smooth_shading_is_automatic_and_non_solid_paints_keep_their_base_paint() {
|
||||
let mut n = profile_node();
|
||||
n.appearance.effects.push(fx());
|
||||
assert!(!n.appearance.effects[0].params.as_object().unwrap().contains_key("smoothShading"));
|
||||
let smooth = revolve_art(&n).unwrap();
|
||||
assert!(
|
||||
smooth.children().unwrap()[0].children().unwrap().iter().any(|face| {
|
||||
matches!(face.appearance.items.first(), Some(AppearanceItem::Fill(fill)) if matches!(fill.paint, Paint::Gradient(_)))
|
||||
})
|
||||
);
|
||||
n.appearance.effects[0].params["smoothShading"] = json!(false);
|
||||
assert_eq!(revolve_art(&n), Some(smooth.clone()));
|
||||
n.appearance.effects[0].params["smoothShading"] = json!(true);
|
||||
assert_eq!(revolve_art(&n), Some(smooth));
|
||||
use vectorcraft_doc::color::{Gradient, GradientPaint};
|
||||
let base = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
||||
n.appearance.set_fill(base.clone());
|
||||
let art = revolve_art(&n).unwrap();
|
||||
let faces = art.children().unwrap()[0].children().unwrap();
|
||||
assert!(faces.iter().all(|f| matches!(f.appearance.items.first(), Some(AppearanceItem::Fill(fill)) if fill.paint == base)));
|
||||
assert!(faces.iter().any(|f| f.appearance.items.iter().any(|item| {
|
||||
matches!(item, AppearanceItem::Fill(fill) if matches!(&fill.paint, Paint::Gradient(g) if g.gradient.stops.iter().all(|s| s.color == Color::BLACK) && g.gradient.stops[0].opacity != g.gradient.stops[1].opacity))
|
||||
})));
|
||||
assert_eq!(expand_revolve_art(&n), Some(art), "unknown paint opacity keeps complete surfaces");
|
||||
}
|
||||
#[test]
|
||||
fn missing_cap_parameter_defaults_on_and_explicit_false_restores_open_ends() {
|
||||
let mut n = profile_node();
|
||||
let mut effect = fx();
|
||||
effect.params["offset"] = json!(20);
|
||||
effect.params["rotationX"] = json!(-35);
|
||||
effect.params.as_object_mut().unwrap().remove("cap");
|
||||
n.appearance.effects.push(effect);
|
||||
let capped = revolve_art(&n).unwrap();
|
||||
n.appearance.effects[0].params["cap"] = json!(false);
|
||||
let uncapped = revolve_art(&n).unwrap();
|
||||
assert_ne!(uncapped, capped);
|
||||
n.appearance.effects[0].params["cap"] = json!(true);
|
||||
assert_eq!(revolve_art(&n), Some(capped));
|
||||
n.appearance.effects[0].params["cap"] = json!(false);
|
||||
assert_eq!(revolve_art(&n), Some(uncapped));
|
||||
}
|
||||
#[test]
|
||||
fn source_stays_editable_surface_moves_and_hidden_effect_restores_it() {
|
||||
let mut n = profile_node();
|
||||
let before = n.path_data().unwrap().clone();
|
||||
|
||||
@@ -651,6 +651,76 @@ mod tests {
|
||||
effects::revolve_art(&node(s, id)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_revolve_commands_validate_save_expand_and_undo() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":300,"height":300})).unwrap();
|
||||
let r = s.execute("path.create", &json!({"d":"M150 40 C190 60 190 140 150 160"})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null,"params":{"offset":20,"segments":16}})).unwrap();
|
||||
let initial = node(&s, id);
|
||||
let source = initial.path_data().unwrap().clone();
|
||||
assert!(!initial.appearance.effects[0].params.as_object().unwrap().contains_key("smoothShading"));
|
||||
assert!(initial.appearance.effects[0].params["cap"].as_bool().unwrap());
|
||||
let mut gradients = 0;
|
||||
effects_for_test(&s, id).walk(&mut |face| {
|
||||
gradients += face.appearance.items.iter().filter(|item| {
|
||||
matches!(item, vectorcraft_doc::AppearanceItem::Fill(fill) if matches!(fill.paint, vectorcraft_doc::color::Paint::Gradient(_)))
|
||||
}).count();
|
||||
});
|
||||
assert!(gradients > 0, "commands use smooth vector shading by default");
|
||||
s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"lightAzimuth":30}})).unwrap();
|
||||
let smooth = node(&s, id);
|
||||
assert_eq!(smooth.path_data(), Some(&source));
|
||||
assert_ne!(effects_for_test(&s, id), effects::revolve_art(&initial).unwrap());
|
||||
let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
assert_eq!(effects::revolve_art(restored.node(id).unwrap()), Some(effects_for_test(&s, id)));
|
||||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||||
assert!(!effects::has_revolve(&node(&s, id)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id), smooth);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id), initial);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_caps_save_export_expand_and_undo_without_changing_the_source() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":300,"height":300})).unwrap();
|
||||
let r = s.execute("path.create", &json!({"d":"M150 40 L150 160"})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
let source = node(&s, id).path_data().unwrap().clone();
|
||||
s.execute(
|
||||
"effect.apply",
|
||||
&json!({"effect":"threeD.revolve","ids":[id.0],"item":null,
|
||||
"params":{"offset":30,"rotationX":-35,"segments":16,"expandVisibleOnly":false,"cap":false}}),
|
||||
)
|
||||
.unwrap();
|
||||
let uncapped = node(&s, id);
|
||||
assert_eq!(uncapped.appearance.effects[0].params["cap"], json!(false));
|
||||
let count = |n: &vectorcraft_doc::Node| n.children().unwrap()[0].children().unwrap().len();
|
||||
let open_count = count(&effects_for_test(&s, id));
|
||||
s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"cap":true}})).unwrap();
|
||||
let capped = node(&s, id);
|
||||
let art = effects_for_test(&s, id);
|
||||
assert_eq!(count(&art), open_count + 2, "each endpoint adds one continuous vector cap");
|
||||
assert_eq!(capped.path_data(), Some(&source));
|
||||
assert!(s.execute("effect.setParams", &json!({"index":0,"ids":[id.0],"item":null,"params":{"cap":"yes"}})).is_err());
|
||||
assert_eq!(node(&s, id), capped, "a bad boolean does not mutate the document");
|
||||
let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["cap"], json!(true));
|
||||
assert_eq!(effects::revolve_art(restored.node(id).unwrap()), Some(art.clone()));
|
||||
let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap();
|
||||
assert!(vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap().starts_with(b"%PDF"));
|
||||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||||
assert_eq!(count(&node(&s, id)), count(&art));
|
||||
assert!(!effects::has_revolve(&node(&s, id)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id), capped);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id), uncapped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_visibility_parameter_saves_expands_and_undoes_without_other_appearance_changes() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -285,6 +285,32 @@ fn gradients_exported_as_shadings_and_reimported() {
|
||||
assert!((g.geom.unwrap().start - Point::new(200.0, 50.0)).hypot() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_revolve_exports_axial_vector_shading_without_images_and_keeps_the_source_live() {
|
||||
let mut d = doc(200.0, 200.0);
|
||||
let bp = vectorcraft_geom::BezPath::from_svg("M100 30 C140 50 140 120 100 160").unwrap();
|
||||
let mut n = Node::path(NodeId(0), PathData::from_bezpath(&bp), Appearance::basic(Paint::solid(Color::rgb(0.2, 0.55, 0.9)), Paint::None, 0.0));
|
||||
n.appearance.effects.push(vectorcraft_doc::Effect {
|
||||
id: "threeD.revolve".into(),
|
||||
visible: true,
|
||||
params: serde_json::json!({"offset":20,"rotationX":-25,"cap":true,"segments":16}),
|
||||
});
|
||||
add(&mut d, n);
|
||||
let before = d.clone();
|
||||
let pdf = uncompressed(&d).replace(' ', "");
|
||||
assert!(pdf.matches("/ShadingType2").count() > 50);
|
||||
assert!(!pdf.contains("/Subtype/Image"));
|
||||
assert_eq!(d, before);
|
||||
let back = roundtrip(&d);
|
||||
let strokes: Vec<_> = leaves(&back)
|
||||
.into_iter()
|
||||
.flat_map(|n| n.appearance.items.iter())
|
||||
.filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None })
|
||||
.collect();
|
||||
assert!(strokes.len() >= 2, "continuous cap rims remain vector strokes");
|
||||
assert!(strokes.iter().all(|s| s.join == LineJoin::Round), "seam strokes keep round joins in PDF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_objects_and_template_layers_skipped() {
|
||||
let mut d = doc(200.0, 200.0);
|
||||
|
||||
@@ -40,6 +40,42 @@ fn lum(p: [u8; 4]) -> u32 {
|
||||
|
||||
const WHITE: u32 = 765;
|
||||
|
||||
#[test]
|
||||
fn revolve_caps_cover_the_opening_and_match_export_and_visible_only_expansion() {
|
||||
let path = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_svg("M70 30 L70 70").unwrap());
|
||||
let mut n = Node::path(NodeId(0), path, Appearance::basic(Paint::solid(Color::rgb(0.9, 0.2, 0.1)), Paint::None, 0.0));
|
||||
for (angle, opacity) in [(360.0, 1.0), (90.0, 1.0), (360.0, 0.5)] {
|
||||
n.opacity = opacity;
|
||||
n.appearance.effects = vec![fx(
|
||||
"threeD.revolve",
|
||||
json!({"offset":20,"rotationX":-35,"lightAzimuth":0,
|
||||
"angle":angle,"segments":32,"cap":false,"expandVisibleOnly":true}),
|
||||
)];
|
||||
let open = render(&doc_with(Document::new(100.0, 100.0), n.clone()));
|
||||
n.appearance.effects[0].params["cap"] = json!(true);
|
||||
let d = doc_with(Document::new(100.0, 100.0), n.clone());
|
||||
let live = render(&d);
|
||||
assert_ne!(live.pixels, open.pixels, "caps change the opening: {angle}, {opacity}");
|
||||
if angle == 360.0 && opacity == 1.0 {
|
||||
let pixel = (34 * 100 + 50) * 4;
|
||||
assert!(live.pixels[pixel] as u16 > open.pixels[pixel] as u16 + 30, "the lit cap covers the visible interior");
|
||||
}
|
||||
assert_eq!(live.pixels, render(&vectorcraft_effects::bake_document(&d).unwrap()).pixels);
|
||||
let mut expanded = d.clone();
|
||||
let id = expanded.layers[0].children().unwrap()[0].id;
|
||||
let source = expanded.node(id).unwrap().clone();
|
||||
let mut stroke = |_: &mut Document, _: &vectorcraft_geom::PathData, _: vectorcraft_geom::FillRule, _: &vectorcraft_doc::StrokeLayer| None;
|
||||
let art = vectorcraft_effects::expand_leaf(&mut expanded, &source, &mut stroke).unwrap();
|
||||
*expanded.node_mut(id).unwrap() = art;
|
||||
let flat = render(&expanded);
|
||||
let difference: u64 = live.pixels.iter().zip(&flat.pixels).map(|(a, b)| a.abs_diff(*b) as u64).sum();
|
||||
assert!(difference as f64 / (live.pixels.len() as f64) < 1.0, "visible-only expansion preserves cap appearance");
|
||||
if opacity < 1.0 {
|
||||
assert_eq!(live.pixels, flat.pixels);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_canvas_matches_baked_art_and_shades_its_surface() {
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
@@ -99,6 +135,95 @@ fn solid_effects_canvas_matches_baked_art_and_shades_their_faces() {
|
||||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
let mut n = Node::path(NodeId(0), path, Appearance::basic(Paint::solid(Color::rgb(0.2, 0.55, 0.9)), Paint::None, 0.0));
|
||||
n.appearance
|
||||
.effects
|
||||
.push(fx("threeD.revolve", json!({"offset":30,"segments":16,"lightAzimuth":0,"lightElevation":0,"ambient":10,"lightIntensity":80})));
|
||||
let d = doc_with(Document::new(100.0, 100.0), n);
|
||||
let smooth = render(&d);
|
||||
let levels = |r: &Rendered| (14..66).map(|x| r.pixels[(50 * 100 + x) * 4 + 2]).collect::<std::collections::BTreeSet<_>>().len();
|
||||
// More colour levels than radial facets proves interpolation inside the faces.
|
||||
assert!(levels(&smooth) > 16, "default rendering interpolates lighting: {} blue levels", levels(&smooth));
|
||||
let jump = |r: &Rendered| (15..66).map(|x| r.pixels[(50 * 100 + x) * 4 + 2].abs_diff(r.pixels[(50 * 100 + x - 1) * 4 + 2])).max().unwrap();
|
||||
assert!(jump(&smooth) < 20, "default lighting has no abrupt changes between face colours");
|
||||
assert_eq!(smooth.pixels, render(&vectorcraft_effects::bake_document(&d).unwrap()).pixels);
|
||||
assert_eq!(smooth.pixels, render_threads(&d, 4).pixels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_rim_has_no_spikes_at_high_zoom_and_keeps_seams_covered() {
|
||||
use vectorcraft_doc::AppearanceItem;
|
||||
let path = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_svg("M70 30 L70 70").unwrap());
|
||||
let mut n = Node::path(NodeId(0), path, Appearance::basic(Paint::solid(Color::rgb(0.2, 0.55, 0.9)), Paint::None, 0.0));
|
||||
n.appearance.effects.push(fx("threeD.revolve", json!({"offset":20,"rotationX":-18,"segments":24,"cap":true})));
|
||||
let d = doc_with(Document::new(100.0, 100.0), n);
|
||||
let baked = vectorcraft_effects::bake_document(&d).unwrap();
|
||||
let mut fills = baked.clone();
|
||||
fn remove_edges(n: &mut Node) {
|
||||
n.appearance.items.retain(|i| matches!(i, AppearanceItem::Fill(_)));
|
||||
for child in n.children_mut().into_iter().flatten() {
|
||||
remove_edges(Arc::make_mut(child));
|
||||
}
|
||||
}
|
||||
for layer in &mut fills.layers {
|
||||
remove_edges(Arc::make_mut(layer));
|
||||
}
|
||||
let bounds = fills.layers[0].geometric_bounds().unwrap();
|
||||
let zoom = 24.0;
|
||||
let xf = Affine::translate((600.0 - 50.0 * zoom, 50.0 - bounds.y0 * zoom)) * Affine::scale(zoom);
|
||||
let draw =
|
||||
|doc: &Document| Renderer::new().render(doc, 1200, 400, xf, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() });
|
||||
let reference = draw(&fills);
|
||||
let live = draw(&d);
|
||||
// The seam stroke may extend half its width, plus antialiasing, past the fill
|
||||
// silhouette. Miter spikes previously reached several times that distance.
|
||||
let reach = (0.375 * zoom) as u32 + 2;
|
||||
let top = |img: &Rendered| (0..1200).map(|x| (0..400).find(|y| lum(img.pixel(x, *y)) < 600).unwrap_or(400)).collect::<Vec<_>>();
|
||||
let fill_top = top(&reference);
|
||||
for (x, y) in top(&live).into_iter().enumerate().filter(|(_, y)| *y < 400) {
|
||||
let near = *fill_top[x.saturating_sub(reach as usize)..=(x + reach as usize).min(1199)].iter().min().unwrap();
|
||||
assert!(y + reach >= near, "rim spike at x {x}: ink starts at {y}, nearest fill at {near}");
|
||||
}
|
||||
assert!((200..1000).all(|x| lum(live.pixel(x, 200)) < 600), "seam coverage leaves no white gaps across the cap");
|
||||
assert_eq!(live.pixels, draw(&baked).pixels, "live and exported geometry agree at high zoom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuous_revolve_caps_keep_one_colour_along_the_curved_rim_at_high_zoom() {
|
||||
let path = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_svg("M70 30 L70 70").unwrap());
|
||||
for rotation in [-18.0f64, 18.0] {
|
||||
for shade in [false, true] {
|
||||
let mut n = Node::path(NodeId(0), path.clone(), Appearance::basic(Paint::solid(Color::rgb(0.2, 0.55, 0.9)), Paint::None, 0.0));
|
||||
n.appearance.effects.push(fx("threeD.revolve", json!({"offset":20,"rotationX":rotation,"segments":8,"cap":true,"shade":shade})));
|
||||
let d = doc_with(Document::new(100.0, 100.0), n);
|
||||
let center_y = 50.0 + if rotation < 0.0 { -20.0 } else { 20.0 } * rotation.to_radians().cos();
|
||||
let zoom = 24.0;
|
||||
let xf = Affine::translate((600.0 - 50.0 * zoom, 220.0 - center_y * zoom)) * Affine::scale(zoom);
|
||||
let draw = |doc: &Document| {
|
||||
Renderer::new().render(doc, 1200, 440, xf, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
|
||||
};
|
||||
let live = draw(&d);
|
||||
let color = live.pixel(600, 220);
|
||||
assert!(lum(color) < 600, "the visible cap is filled");
|
||||
// Sample 0.15 pt inside the projected ellipse. A triangle fan with
|
||||
// seam strokes lets body colours intrude here between mesh corners.
|
||||
for step in 1..80 {
|
||||
let theta = std::f64::consts::PI * step as f64 / 80.0;
|
||||
let p = xf * Point::new(50.0 + 19.85 * theta.cos(), center_y + 19.85 * rotation.to_radians().abs().sin() * theta.sin());
|
||||
let pixel = live.pixel(p.x.round() as u32, p.y.round() as u32);
|
||||
assert!(
|
||||
pixel.iter().zip(color).all(|(a, b)| a.abs_diff(b) <= 1),
|
||||
"cap rim is continuous: rotation {rotation}, shade {shade}, theta {theta}, {pixel:?} vs {color:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(live.pixels, draw(&vectorcraft_effects::bake_document(&d).unwrap()).pixels);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_visible_surface_expansion_preserves_the_view_and_transparent_art() {
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
@@ -109,13 +234,19 @@ fn revolve_visible_surface_expansion_preserves_the_view_and_transparent_art() {
|
||||
vectorcraft_geom::PathData::from_bezpath(&bp),
|
||||
Appearance::basic(Paint::solid(Color::rgb(0.2, 0.55, 0.9)), Paint::None, 0.0),
|
||||
);
|
||||
for (angle, rotation, perspective, opacity) in
|
||||
[(360.0, 0.0, 0.0, 1.0), (360.0, -35.0, 55.0, 1.0), (150.0, -28.0, 40.0, 1.0), (360.0, -35.0, 55.0, 0.5)]
|
||||
{
|
||||
for (angle, rotation, perspective, opacity, shade) in [
|
||||
(360.0, 0.0, 0.0, 1.0, false),
|
||||
(360.0, -35.0, 55.0, 1.0, false),
|
||||
(150.0, -28.0, 40.0, 1.0, false),
|
||||
(360.0, -35.0, 55.0, 0.5, false),
|
||||
(360.0, -35.0, 55.0, 1.0, true),
|
||||
(150.0, -28.0, 40.0, 1.0, true),
|
||||
(360.0, -35.0, 55.0, 0.5, true),
|
||||
] {
|
||||
n.opacity = opacity;
|
||||
n.appearance.effects = vec![fx(
|
||||
"threeD.revolve",
|
||||
json!({"angle":angle,"rotationX":rotation,"rotationY":25,"perspective":perspective,"segments":32,"expandVisibleOnly":true}),
|
||||
json!({"angle":angle,"rotationX":rotation,"rotationY":25,"perspective":perspective,"segments":32,"expandVisibleOnly":true,"shade":shade}),
|
||||
)];
|
||||
let d = doc_with(Document::new(100.0, 100.0), n.clone());
|
||||
let live = render(&d);
|
||||
|
||||
@@ -21,6 +21,22 @@ fn fx(id: &str) -> Effect {
|
||||
Effect { id: id.into(), params: json!({}), visible: true }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_caps_export_as_additional_vector_faces_and_leave_source_editable() {
|
||||
let path = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_svg("M100 30 L100 160").unwrap());
|
||||
let mut n = Node::path(NodeId(0), path, Appearance::basic(Paint::solid(Color::rgb(0.8, 0.2, 0.1)), Paint::None, 0.0));
|
||||
n.appearance.effects.push(Effect { params: json!({"offset":30,"rotationX":-35,"segments":16,"cap":false}), ..fx("threeD.revolve") });
|
||||
let mut d = doc_with(n);
|
||||
let open = export(&d, &ExportOptions::default());
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
d.node_mut(id).unwrap().appearance.effects[0].params["cap"] = json!(true);
|
||||
let before = d.clone();
|
||||
let capped = export(&d, &ExportOptions::default());
|
||||
assert_eq!(capped.matches("<path").count(), open.matches("<path").count() + 2, "two continuous caps, each with a fill and rim stroke");
|
||||
assert!(!capped.contains("<image"));
|
||||
assert_eq!(d, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_exports_shaded_vector_faces_and_keeps_the_source_live() {
|
||||
let bp = vectorcraft_geom::BezPath::from_svg("M100 30 L130 50 L130 140 L100 160").unwrap();
|
||||
@@ -38,6 +54,27 @@ fn revolve_exports_shaded_vector_faces_and_keeps_the_source_live() {
|
||||
assert_eq!(d, before, "export leaves source editable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_revolve_exports_vector_gradients_without_rasterizing_or_editing_the_source() {
|
||||
let bp = vectorcraft_geom::BezPath::from_svg("M100 30 C140 50 140 120 100 160").unwrap();
|
||||
let mut n = Node::path(
|
||||
NodeId(0),
|
||||
vectorcraft_geom::PathData::from_bezpath(&bp),
|
||||
Appearance::basic(Paint::solid(Color::rgb(0.2, 0.55, 0.9)), Paint::None, 0.0),
|
||||
);
|
||||
n.appearance.effects.push(Effect { params: json!({"offset":20,"rotationX":-25,"cap":true,"segments":16}), ..fx("threeD.revolve") });
|
||||
let d = doc_with(n);
|
||||
let before = d.clone();
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert!(svg.matches("<linearGradient").count() > 50);
|
||||
assert!(svg.contains("gradientUnits=\"userSpaceOnUse\""));
|
||||
assert!(svg.matches("<path").count() > 100);
|
||||
assert!(svg.matches("stroke-linejoin=\"round\"").count() > 100, "seam strokes keep bounded joins in SVG");
|
||||
assert!(!svg.contains("stroke-miterlimit"));
|
||||
assert!(!svg.contains("<image") && !svg.contains("<filter"));
|
||||
assert_eq!(d, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_strokes_own_glow_filters_the_stroke_element_only() {
|
||||
let mut n = Node::path(
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Curved projected rings shared by a cap and its adjoining body faces.
|
||||
|
||||
use vectorcraft_geom::{CubicBez, Shape};
|
||||
|
||||
use super::*;
|
||||
|
||||
const CURVE_TOLERANCE: f64 = 0.001;
|
||||
const MAX_RING_CURVES: usize = 32_768;
|
||||
|
||||
pub(super) struct RingArc {
|
||||
curves: Vec<CubicBez>,
|
||||
points: Vec<Point>,
|
||||
}
|
||||
|
||||
pub(super) struct Ring {
|
||||
pub arcs: Vec<RingArc>,
|
||||
center: V3,
|
||||
seed: [V3; 3],
|
||||
near: f64,
|
||||
far: f64,
|
||||
}
|
||||
|
||||
fn project(v: V3, origin: Point, eye: Option<f64>) -> Point {
|
||||
let factor = perspective_scale(eye, v.2);
|
||||
Point::new(origin.x + v.0 * factor, origin.y + v.1 * factor)
|
||||
}
|
||||
|
||||
fn fit_arc(
|
||||
sample: &impl Fn(f64) -> (Point, Vec2),
|
||||
a: f64,
|
||||
b: f64,
|
||||
depth: usize,
|
||||
curves: &mut Vec<CubicBez>,
|
||||
budget: &mut usize,
|
||||
) -> Result<(), String> {
|
||||
let (p, dp) = sample(a);
|
||||
let (q, dq) = sample(b);
|
||||
let curve = CubicBez::new(p, p + dp * ((b - a) / 3.0), q - dq * ((b - a) / 3.0), q);
|
||||
let error = [0.25, 0.5, 0.75].into_iter().map(|t| curve.eval(t).distance(sample(a + (b - a) * t).0)).fold(0.0, f64::max);
|
||||
if error > CURVE_TOLERANCE && depth < 10 {
|
||||
let mid = (a + b) / 2.0;
|
||||
fit_arc(sample, a, mid, depth + 1, curves, budget)?;
|
||||
fit_arc(sample, mid, b, depth + 1, curves, budget)?;
|
||||
} else {
|
||||
*budget += 1;
|
||||
if *budget > MAX_RING_CURVES {
|
||||
return Err("Revolve surface is too complex; simplify the profile".into());
|
||||
}
|
||||
curves.push(curve);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Ring {
|
||||
pub fn new(end: Point, bounds: Rect, o: Revolve, steps: usize, budget: &mut usize) -> Result<Self, String> {
|
||||
let origin = o.rotation_origin(bounds);
|
||||
let radius = end.x - origin.x;
|
||||
let eye = eye_distance(o.perspective, (bounds.x0 - origin.x).abs().max((bounds.x1 - origin.x).abs()).max(bounds.height().abs()).max(1.0));
|
||||
let sweep = o.angle.to_radians();
|
||||
let vertex = |t: f64| o.rotate(V3(radius * t.cos(), end.y - origin.y, radius * t.sin()));
|
||||
let sample = |t: f64| {
|
||||
let v = vertex(t);
|
||||
let d = o.rotate(V3(-radius * t.sin(), 0.0, radius * t.cos()));
|
||||
let f = perspective_scale(eye, v.2);
|
||||
let df = eye.map_or(0.0, |e| if e - v.2 > e * 1e-3 { f * f * d.2 / e } else { 0.0 });
|
||||
(project(v, origin, eye), Vec2::new(d.0 * f + v.0 * df, d.1 * f + v.1 * df))
|
||||
};
|
||||
// Visibility gets a bounded dense polygon; output keeps the cubic outline.
|
||||
// Every body edge uses these same samples and curves, preventing rim gaps.
|
||||
let samples = (512.0 * o.angle / 360.0 / steps as f64).ceil().max(1.0) as usize;
|
||||
let center = o.rotate(V3(0.0, end.y - origin.y, 0.0));
|
||||
let (mut near, mut far) = (center.2, center.2);
|
||||
let mut arcs = vec![];
|
||||
for j in 0..steps {
|
||||
let (a, b) = (sweep * j as f64 / steps as f64, sweep * (j + 1) as f64 / steps as f64);
|
||||
let mut curves = vec![];
|
||||
fit_arc(&sample, a, b, 0, &mut curves, budget)?;
|
||||
let points = (0..=samples)
|
||||
.map(|k| {
|
||||
let v = vertex(a + (b - a) * k as f64 / samples as f64);
|
||||
near = near.max(v.2);
|
||||
far = far.min(v.2);
|
||||
project(v, origin, eye)
|
||||
})
|
||||
.collect();
|
||||
arcs.push(RingArc { curves, points });
|
||||
}
|
||||
Ok(Self { arcs, center, seed: [center, vertex(0.0), vertex(sweep / steps as f64)], near, far })
|
||||
}
|
||||
|
||||
pub fn face(&self, bounds: Rect, o: Revolve, light: V3, outward: f64) -> Result<Option<Face>, String> {
|
||||
let origin = o.rotation_origin(bounds);
|
||||
let eye = eye_distance(o.perspective, (bounds.x0 - origin.x).abs().max((bounds.x1 - origin.x).abs()).max(bounds.height().abs()).max(1.0));
|
||||
let Some(mut face) = project_face(&self.seed, None, o, origin, eye, light)? else { return Ok(None) };
|
||||
let Some(first) = self.arcs.first().and_then(|a| a.points.first()).copied() else { return Ok(None) };
|
||||
let center = project(self.center, origin, eye);
|
||||
let mut path = BezPath::new();
|
||||
path.move_to(if o.angle == 360.0 { first } else { center });
|
||||
if o.angle < 360.0 {
|
||||
path.line_to(first);
|
||||
}
|
||||
face.points = vec![center, first];
|
||||
for arc in &self.arcs {
|
||||
arc.append(&mut path, &mut face.points, false);
|
||||
}
|
||||
path.close_path();
|
||||
if path.area().abs() < 1e-10 {
|
||||
return Ok(None);
|
||||
}
|
||||
face.outline = Some(PathData::from_bezpath(&path));
|
||||
face.cap = true;
|
||||
// An outward-facing disk covers its rim's seam strokes. An inward-facing
|
||||
// disk stays behind the body, including when the view turns upside down.
|
||||
face.depth = if o.rotate(V3(0.0, outward, 0.0)).2 >= 0.0 { self.near } else { self.far };
|
||||
Ok(Some(face))
|
||||
}
|
||||
}
|
||||
|
||||
impl RingArc {
|
||||
fn append(&self, path: &mut BezPath, points: &mut Vec<Point>, reverse: bool) {
|
||||
if reverse {
|
||||
for c in self.curves.iter().rev() {
|
||||
path.curve_to(c.p2, c.p1, c.p0);
|
||||
}
|
||||
points.extend(self.points.iter().rev().skip(1));
|
||||
} else {
|
||||
for c in &self.curves {
|
||||
path.curve_to(c.p1, c.p2, c.p3);
|
||||
}
|
||||
points.extend(self.points.iter().skip(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn curve_edges(face: &mut Face, edges: &[(usize, &RingArc, bool)]) {
|
||||
if edges.is_empty() {
|
||||
return;
|
||||
}
|
||||
if face.points.len() == 4
|
||||
&& let (Some((_, bottom, _)), Some((_, top, _))) =
|
||||
(edges.iter().find(|(edge, _, _)| *edge == 1), edges.iter().find(|(edge, _, _)| *edge == 3))
|
||||
{
|
||||
// Connecting equal angular samples avoids the long, narrow triangles
|
||||
// produced by ear clipping a cylinder's concave projected boundary.
|
||||
let strips: Vec<_> = top
|
||||
.points
|
||||
.windows(2)
|
||||
.zip(bottom.points.windows(2))
|
||||
.filter_map(|(a, b)| {
|
||||
let ([a0, a1], [b0, b1]) = (a, b) else { return None };
|
||||
Some(vec![*a0, *b0, *b1, *a1])
|
||||
})
|
||||
.collect();
|
||||
let areas: Vec<_> = strips
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let origin = p.first().copied().unwrap_or_default();
|
||||
p.iter().zip(p.iter().cycle().skip(1)).take(p.len()).map(|(a, b)| (*a - origin).cross(*b - origin)).sum::<f64>()
|
||||
})
|
||||
.collect();
|
||||
// A grazing projection can fold a radial strip back over itself. Keep
|
||||
// the original boundary's winding there rather than unioning its folds.
|
||||
if !(areas.iter().any(|a| *a > 1e-10) && areas.iter().any(|a| *a < -1e-10)) {
|
||||
face.visibility_pieces = Some(strips);
|
||||
}
|
||||
}
|
||||
let Some(first) = face.points.first().copied() else { return };
|
||||
let mut path = BezPath::new();
|
||||
path.move_to(first);
|
||||
let mut points = vec![first];
|
||||
for (i, next) in face.points.iter().cycle().skip(1).take(face.points.len()).enumerate() {
|
||||
if let Some((_, arc, reverse)) = edges.iter().find(|(edge, _, _)| *edge == i) {
|
||||
arc.append(&mut path, &mut points, *reverse);
|
||||
} else {
|
||||
path.line_to(*next);
|
||||
points.push(*next);
|
||||
}
|
||||
}
|
||||
path.close_path();
|
||||
points.pop(); // The final closing edge already repeated the first point.
|
||||
face.points = points;
|
||||
face.outline = Some(PathData::from_bezpath(&path));
|
||||
}
|
||||
+185
-46
@@ -1,8 +1,8 @@
|
||||
//! Portable 3D effect geometry: Revolve, Extrude & Bevel, Inflate and Rotate. No windowing, GPU or
|
||||
//! document dependencies.
|
||||
//!
|
||||
//! Open profiles generate uncapped surfaces. Faces are projected and sorted back to front,
|
||||
//! then flat shaded. This vector preview uses a painter's algorithm, not a depth buffer:
|
||||
//! Open profiles optionally close their circular ends with caps. Faces are projected and sorted back to front,
|
||||
//! then flat or smoothly shaded with vector gradients. This preview uses a painter's algorithm, not a depth buffer:
|
||||
//! intersecting profiles can have ambiguous visibility and are not solid modelling inputs.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
@@ -10,8 +10,11 @@ mod solid;
|
||||
mod visibility;
|
||||
pub use solid::{Bevel, BevelShape, Extrude, Facet, Inflate, View, extrude, inflate, plane};
|
||||
pub use visibility::{VisibleFace, visible_faces};
|
||||
mod shading;
|
||||
pub use shading::ShadeGradient;
|
||||
mod caps;
|
||||
|
||||
use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect};
|
||||
use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect, Vec2, kurbo::ParamCurveDeriv};
|
||||
|
||||
/// Resource limits shared by desktop, CLI and browser evaluation.
|
||||
const MAX_PROFILE_POINTS: usize = 1024;
|
||||
@@ -70,6 +73,11 @@ pub struct Revolve {
|
||||
/// Ambient Light, 0 to 2.
|
||||
pub ambient: f64,
|
||||
pub shade: bool,
|
||||
/// Interpolate profile normals using vector gradients. Enabled by default.
|
||||
pub smooth_shading: bool,
|
||||
/// Close circular endpoint rings of open profiles. Partial sweeps get sectors;
|
||||
/// the angular cut sides are left open. Enabled by default.
|
||||
pub cap_ends: bool,
|
||||
}
|
||||
|
||||
/// The default light's Rotation and Height: the light of the earlier default direction (45° to the
|
||||
@@ -94,6 +102,8 @@ impl Default for Revolve {
|
||||
light_softness: 0.0,
|
||||
ambient: 0.25,
|
||||
shade: true,
|
||||
smooth_shading: true,
|
||||
cap_ends: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -259,16 +269,27 @@ impl Revolve {
|
||||
}
|
||||
}
|
||||
|
||||
/// One opaque vector polygon, already projected. Brightness multiplies the source colour.
|
||||
/// One projected vector face. Brightness multiplies the source colour; an optional
|
||||
/// gradient replaces that constant brightness for a smoothly shaded triangle.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Face {
|
||||
pub points: Vec<Point>,
|
||||
pub depth: f64,
|
||||
pub brightness: f64,
|
||||
pub gradient: Option<ShadeGradient>,
|
||||
/// Curved vector boundary; points remain a bounded visibility approximation.
|
||||
pub outline: Option<PathData>,
|
||||
/// A single endpoint disk or sector, with its axis centre first in points.
|
||||
pub cap: bool,
|
||||
/// Bounded radial strips for a body face joining two curved endpoint rings.
|
||||
pub visibility_pieces: Option<Vec<Vec<Point>>>,
|
||||
}
|
||||
|
||||
impl Face {
|
||||
pub fn path(&self) -> PathData {
|
||||
if let Some(path) = &self.outline {
|
||||
return path.clone();
|
||||
}
|
||||
let mut bp = BezPath::new();
|
||||
if let Some(first) = self.points.first() {
|
||||
bp.move_to(*first);
|
||||
@@ -281,6 +302,71 @@ impl Face {
|
||||
}
|
||||
}
|
||||
|
||||
/// Body faces and a cap's planar seed use identical two-sided shading and projection.
|
||||
fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Point, eye: Option<f64>, light: V3) -> Result<Option<Face>, String> {
|
||||
let Some(first) = vertices.first() else { return Ok(None) };
|
||||
// A triangle fan also handles a quad whose first edge lies on the axis.
|
||||
let mut normal = vertices
|
||||
.get(1..)
|
||||
.unwrap_or_default()
|
||||
.windows(2)
|
||||
.filter_map(|pair| {
|
||||
let [a, b] = pair else { return None };
|
||||
Some(a.sub(*first).cross(b.sub(*first)).unit())
|
||||
})
|
||||
.find(|n| n.dot(*n) >= 0.5)
|
||||
.unwrap_or_default();
|
||||
if normal.dot(normal) < 0.5 {
|
||||
return Ok(None);
|
||||
}
|
||||
// Profiles may run up or down. Shade the visible side of this two-sided surface.
|
||||
if normal.2 < 0.0 {
|
||||
normal = V3(-normal.0, -normal.1, -normal.2);
|
||||
}
|
||||
let flat_brightness = brightness(&o, normal, light);
|
||||
let points: Vec<_> = vertices
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let factor = perspective_scale(eye, v.2);
|
||||
Point::new(origin.x + v.0 * factor, origin.y + v.1 * factor)
|
||||
})
|
||||
.collect();
|
||||
if points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) {
|
||||
return Err("Revolve projection is not finite".into());
|
||||
}
|
||||
let area = points
|
||||
.iter()
|
||||
.zip(points.iter().cycle().skip(1))
|
||||
.take(points.len())
|
||||
.map(|(a, b)| (a.x - origin.x) * (b.y - origin.y) - (b.x - origin.x) * (a.y - origin.y))
|
||||
.sum::<f64>();
|
||||
if area.abs() < 1e-10 {
|
||||
return Ok(None);
|
||||
}
|
||||
let (brightness, gradient) = if let Some(normals) = normals {
|
||||
let values: Vec<_> = normals
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let n = if n.dot(normal) < 0.0 { V3(-n.0, -n.1, -n.2) } else { *n };
|
||||
brightness(&o, n, light)
|
||||
})
|
||||
.collect();
|
||||
let brightness = if values.is_empty() { flat_brightness } else { values.iter().sum::<f64>() / values.len() as f64 };
|
||||
(brightness, ShadeGradient::triangle(&points, &values))
|
||||
} else {
|
||||
(flat_brightness, None)
|
||||
};
|
||||
Ok(Some(Face {
|
||||
points,
|
||||
depth: vertices.iter().map(|v| v.2).sum::<f64>() / vertices.len() as f64,
|
||||
brightness,
|
||||
gradient,
|
||||
outline: None,
|
||||
cap: false,
|
||||
visibility_pieces: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Evaluate a profile in document space. All subpaths share one axis from `bounds`.
|
||||
/// Invalid or excessively complex inputs return an error, never a partial surface.
|
||||
pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result<Vec<Face>, String> {
|
||||
@@ -297,12 +383,34 @@ pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result<Vec<Fa
|
||||
let eye = eye_distance(o.perspective, radius.max(bounds.height().abs()).max(1.0));
|
||||
let steps = ((o.segments as f64 * o.angle / 360.0).ceil() as usize).max(1);
|
||||
let sweep = o.angle.to_radians();
|
||||
let smooth = o.shade && o.smooth_shading;
|
||||
let [lx, ly, lz] = o.light_direction();
|
||||
let light = V3(lx, ly, lz);
|
||||
let mut faces = Vec::new();
|
||||
let vertex = |p: Point, step: usize| {
|
||||
let (s, c) = (sweep * step as f64 / steps as f64).sin_cos();
|
||||
o.rotate(V3((p.x - axis) * c, p.y - center_y, (p.x - axis) * s))
|
||||
};
|
||||
let normal = |tangent: Vec2, step: usize| {
|
||||
let (s, c) = (sweep * step as f64 / steps as f64).sin_cos();
|
||||
o.rotate(V3(tangent.y * c, -tangent.x, tangent.y * s).unit())
|
||||
};
|
||||
let mut mesh_cost = 0;
|
||||
let mut emit = |face: Option<Face>, cost: usize| -> Result<(), String> {
|
||||
if let Some(face) = face {
|
||||
faces.push(face);
|
||||
mesh_cost += cost;
|
||||
if mesh_cost > MAX_FACES {
|
||||
return Err("Revolve surface is too complex; reduce Segments or simplify the profile".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let mut count = 0;
|
||||
let mut curve_budget = 0;
|
||||
for sp in &path.subpaths {
|
||||
let mut profile = Vec::new();
|
||||
let mut tangents = Vec::new();
|
||||
if let Some(first) = sp.anchors.first() {
|
||||
profile.push(first.p);
|
||||
count += 1;
|
||||
@@ -319,67 +427,98 @@ pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result<Vec<Fa
|
||||
let curve = sp.segment(i);
|
||||
let samples = if sp.segment_is_line(i) { 1 } else { 16 };
|
||||
for k in 1..=samples {
|
||||
profile.push(curve.eval(k as f64 / samples as f64));
|
||||
let t0 = (k - 1) as f64 / samples as f64;
|
||||
let t1 = k as f64 / samples as f64;
|
||||
let b = curve.eval(t1);
|
||||
let tangent = |t: f64| {
|
||||
if !smooth {
|
||||
return Vec2::ZERO;
|
||||
}
|
||||
let a = curve.eval(t0);
|
||||
if samples == 1 {
|
||||
return b - a;
|
||||
}
|
||||
let d = curve.deriv();
|
||||
let v = d.eval(t).to_vec2();
|
||||
if v.hypot2() > 1e-20 {
|
||||
return v;
|
||||
}
|
||||
// Repeated endpoint handles still have a one-sided curve tangent.
|
||||
let v = d.eval(t.clamp(1e-7, 1.0 - 1e-7)).to_vec2();
|
||||
if v.hypot2() > 1e-20 { v } else { b - a }
|
||||
};
|
||||
tangents.push([tangent(t0), tangent(t1)]);
|
||||
profile.push(b);
|
||||
count += 1;
|
||||
if count > MAX_PROFILE_POINTS {
|
||||
return Err("Revolve profile is too complex".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
for pair in profile.windows(2) {
|
||||
let ends = o.cap_ends && !sp.closed && profile.len() >= 2 && profile.first().zip(profile.last()).is_some_and(|(a, b)| a.distance(*b) > 1e-10);
|
||||
let mut rings = [None, None];
|
||||
if ends {
|
||||
for (ring, end) in rings.iter_mut().zip([profile.first(), profile.last()]) {
|
||||
if let Some(end) = end
|
||||
&& (end.x - axis).abs() > 1e-10
|
||||
{
|
||||
*ring = Some(caps::Ring::new(*end, bounds, o, steps, &mut curve_budget)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, (pair, [ta, tb])) in profile.windows(2).zip(tangents).enumerate() {
|
||||
let [a, b] = pair else { continue };
|
||||
if ![a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite() && v.abs() <= 1e8) {
|
||||
return Err("Revolve profile contains invalid coordinates".into());
|
||||
}
|
||||
for j in 0..steps {
|
||||
let vertex = |p: Point, step: usize| {
|
||||
let (s, c) = (sweep * step as f64 / steps as f64).sin_cos();
|
||||
o.rotate(V3((p.x - axis) * c, p.y - center_y, (p.x - axis) * s))
|
||||
let [v0, v1, v2, v3] = [vertex(*a, j), vertex(*b, j), vertex(*b, j + 1), vertex(*a, j + 1)];
|
||||
let first = (i == 0).then(|| rings.first().and_then(Option::as_ref).and_then(|r| r.arcs.get(j))).flatten();
|
||||
let last = (i + 2 == profile.len()).then(|| rings.last().and_then(Option::as_ref).and_then(|r| r.arcs.get(j))).flatten();
|
||||
let projected = |vertices: &[V3], normals: Option<&[V3]>, edges: &[(usize, &caps::RingArc, bool)]| {
|
||||
let mut face = project_face(vertices, normals, o, Point::new(axis, center_y), eye, light)?;
|
||||
if let Some(face) = &mut face {
|
||||
caps::curve_edges(face, edges);
|
||||
}
|
||||
Ok::<_, String>(face)
|
||||
};
|
||||
let vertices = [vertex(*a, j), vertex(*b, j), vertex(*b, j + 1), vertex(*a, j + 1)];
|
||||
// The diagonal gives a nonzero normal even when one profile endpoint is on-axis.
|
||||
let mut normal = vertices[1].sub(vertices[0]).cross(vertices[2].sub(vertices[0])).unit();
|
||||
if normal.dot(normal) < 0.5 {
|
||||
normal = vertices[2].sub(vertices[0]).cross(vertices[3].sub(vertices[0])).unit();
|
||||
}
|
||||
if normal.dot(normal) < 0.5 {
|
||||
continue;
|
||||
}
|
||||
// Profiles may run up or down. Shade the visible side of this two-sided surface.
|
||||
if normal.2 < 0.0 {
|
||||
normal = V3(-normal.0, -normal.1, -normal.2);
|
||||
}
|
||||
let brightness = brightness(&o, normal, light);
|
||||
let points: Vec<_> = vertices
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let factor = perspective_scale(eye, v.2);
|
||||
Point::new(axis + v.0 * factor, center_y + v.1 * factor)
|
||||
})
|
||||
.collect();
|
||||
if points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) {
|
||||
return Err("Revolve projection is not finite".into());
|
||||
}
|
||||
let area = points
|
||||
.iter()
|
||||
.zip(points.iter().cycle().skip(1))
|
||||
.take(4)
|
||||
.map(|(a, b)| (a.x - axis) * (b.y - center_y) - (b.x - axis) * (a.y - center_y))
|
||||
.sum::<f64>();
|
||||
if area.abs() < 1e-10 {
|
||||
continue;
|
||||
}
|
||||
faces.push(Face { points, depth: vertices.iter().map(|v| v.2).sum::<f64>() / 4.0, brightness });
|
||||
if faces.len() > MAX_FACES {
|
||||
return Err("Revolve surface is too complex; reduce Segments or simplify the profile".into());
|
||||
if smooth {
|
||||
let [n0, n1, n2, n3] = [normal(ta, j), normal(tb, j), normal(tb, j + 1), normal(ta, j + 1)];
|
||||
emit(projected(&[v0, v1, v2], Some(&[n0, n1, n2]), &last.map(|arc| (1, arc, false)).into_iter().collect::<Vec<_>>())?, 1)?;
|
||||
emit(projected(&[v0, v2, v3], Some(&[n0, n2, n3]), &first.map(|arc| (2, arc, true)).into_iter().collect::<Vec<_>>())?, 1)?;
|
||||
} else {
|
||||
let edges: Vec<_> = last.map(|arc| (1, arc, false)).into_iter().chain(first.map(|arc| (3, arc, true))).collect();
|
||||
emit(projected(&[v0, v1, v2, v3], None, &edges)?, 1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (index, ring) in rings.iter().enumerate().filter_map(|(i, r)| r.as_ref().map(|r| (i, r))) {
|
||||
let neighbours =
|
||||
if index == 0 { profile.first().zip(profile.get(1)) } else { profile.last().zip(profile.get(profile.len().saturating_sub(2))) };
|
||||
if let Some((end, neighbour)) = neighbours {
|
||||
let dy = end.y - neighbour.y;
|
||||
let outward = if dy.abs() > 1e-10 {
|
||||
dy.signum()
|
||||
} else if end.y < center_y {
|
||||
-1.0
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
// Keep the original tessellation work budget, even though each cap
|
||||
// now produces only one curved vector face.
|
||||
emit(ring.face(bounds, o, light, outward)?, steps)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
faces.sort_by(|a, b| a.depth.total_cmp(&b.depth));
|
||||
Ok(faces)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_caps;
|
||||
#[cfg(test)]
|
||||
mod tests_smooth;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -563,7 +702,7 @@ mod tests {
|
||||
fn malformed_and_degenerate_inputs_are_bounded() {
|
||||
let (p, b) = cylinder();
|
||||
let f = revolve(&p, b, Revolve { segments: usize::MAX, angle: f64::NAN, offset: f64::INFINITY, ..Default::default() }).unwrap();
|
||||
assert!(f.len() <= 128);
|
||||
assert!(f.len() <= 258);
|
||||
assert!(revolve(&p, Rect::new(f64::NAN, 0.0, 1.0, 1.0), Revolve::default()).is_err());
|
||||
assert!(revolve(&PathData::default(), b, Revolve::default()).unwrap().is_empty());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Gouraud lighting represented by an ordinary vector linear gradient on each triangle.
|
||||
|
||||
use vectorcraft_geom::{Point, Vec2};
|
||||
|
||||
/// Document-space gradient placement and the brightness at its two ends. Clipping a
|
||||
/// face must keep this placement unchanged, so newly cut edges retain their lighting.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ShadeGradient {
|
||||
pub start: Point,
|
||||
pub end: Point,
|
||||
pub brightness: [f64; 2],
|
||||
}
|
||||
|
||||
impl ShadeGradient {
|
||||
pub(crate) fn triangle(points: &[Point], values: &[f64]) -> Option<Self> {
|
||||
let [p0, p1, p2] = points else { return None };
|
||||
let [b0, b1, b2] = values else { return None };
|
||||
let u = *p1 - *p0;
|
||||
let v = *p2 - *p0;
|
||||
let det = u.cross(v);
|
||||
if det.abs() < 1e-10 {
|
||||
return None;
|
||||
}
|
||||
// Solve the brightness plane in screen space. Its perpendicular is the gradient
|
||||
// vector, so all three corner values and shared triangle edges agree exactly.
|
||||
let g = Vec2::new(((b1 - b0) * v.y - (b2 - b0) * u.y) / det, (u.x * (b2 - b0) - v.x * (b1 - b0)) / det);
|
||||
let lo = b0.min(*b1).min(*b2);
|
||||
let hi = b0.max(*b1).max(*b2);
|
||||
if hi - lo < 1e-10 || g.hypot2() < 1e-30 {
|
||||
return None;
|
||||
}
|
||||
let start = *p0 + g * ((lo - b0) / g.hypot2());
|
||||
let end = *p0 + g * ((hi - b0) / g.hypot2());
|
||||
[start.x, start.y, end.x, end.y].iter().all(|n| n.is_finite()).then_some(Self { start, end, brightness: [lo, hi] })
|
||||
}
|
||||
|
||||
/// Brightness at a projected point, including a point introduced by visibility clipping.
|
||||
pub fn at(self, p: Point) -> f64 {
|
||||
let v = self.end - self.start;
|
||||
let t = if v.hypot2() > 0.0 { ((p - self.start).dot(v) / v.hypot2()).clamp(0.0, 1.0) } else { 0.0 };
|
||||
self.brightness[0] + (self.brightness[1] - self.brightness[0]) * t
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
use super::*;
|
||||
|
||||
fn cylinder() -> (PathData, Rect) {
|
||||
let mut path = BezPath::new();
|
||||
path.move_to((40.0, 10.0));
|
||||
path.line_to((40.0, 90.0));
|
||||
(PathData::from_bezpath(&path), Rect::new(0.0, 10.0, 40.0, 90.0))
|
||||
}
|
||||
|
||||
fn options() -> Revolve {
|
||||
Revolve { cap_ends: true, rotation_x: 90.0, shade: false, ..Default::default() }
|
||||
}
|
||||
|
||||
fn caps(faces: &[Face]) -> impl Iterator<Item = &Face> {
|
||||
faces.iter().filter(|face| face.cap)
|
||||
}
|
||||
|
||||
fn area(face: &Face) -> f64 {
|
||||
use vectorcraft_geom::Shape;
|
||||
face.path().to_bezpath().area().abs()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_turn_caps_cover_both_circular_ends_and_follow_depth_order() {
|
||||
let (path, bounds) = cylinder();
|
||||
let faces = revolve(&path, bounds, options()).unwrap();
|
||||
assert_eq!(caps(&faces).count(), 2);
|
||||
let expected = 2.0 * std::f64::consts::PI * 40.0f64.powi(2);
|
||||
assert!((caps(&faces).map(area).sum::<f64>() - expected).abs() < 0.05);
|
||||
assert!(caps(&faces).any(|f| (f.depth - 40.0).abs() < 1e-8));
|
||||
assert!(caps(&faces).any(|f| (f.depth + 40.0).abs() < 1e-8));
|
||||
assert!(faces.windows(2).all(|pair| pair[0].depth <= pair[1].depth));
|
||||
assert!(faces.iter().all(|face| face.brightness == 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_turn_caps_are_sectors_and_zero_turn_has_no_geometry() {
|
||||
let (path, bounds) = cylinder();
|
||||
let faces = revolve(&path, bounds, Revolve { angle: 90.0, ..options() }).unwrap();
|
||||
assert_eq!(caps(&faces).count(), 2);
|
||||
let expected = 0.5 * std::f64::consts::PI * 40.0f64.powi(2);
|
||||
assert!((caps(&faces).map(area).sum::<f64>() - expected).abs() < 0.05);
|
||||
assert!(caps(&faces).flat_map(|face| &face.points).all(|point| point.x >= -1e-8 && point.y <= 50.0 + 1e-8));
|
||||
assert!(revolve(&path, bounds, Revolve { angle: 0.0, ..options() }).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsets_and_both_axis_sides_use_the_endpoint_ring_radius() {
|
||||
let (path, bounds) = cylinder();
|
||||
for (edge, offset, radius) in [(Edge::Left, 20.0, 60.0f64), (Edge::Right, 20.0, 20.0f64)] {
|
||||
let faces = revolve(&path, bounds, Revolve { edge, offset, ..options() }).unwrap();
|
||||
let expected = 2.0 * std::f64::consts::PI * radius.powi(2);
|
||||
assert!((caps(&faces).map(area).sum::<f64>() - expected).abs() < 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_coincident_and_on_axis_ends_do_not_gain_caps() {
|
||||
for data in ["M0 10 L40 30 L40 70 L0 90", "M40 10 L60 40 L40 10", "M40 10 L60 40 L40 90 Z"] {
|
||||
let path = PathData::from_bezpath(&BezPath::from_svg(data).unwrap());
|
||||
let bounds = path.bounds().unwrap();
|
||||
let uncapped = revolve(&path, bounds, Revolve { cap_ends: false, ..options() }).unwrap();
|
||||
assert_eq!(revolve(&path, bounds, options()).unwrap(), uncapped, "{data}");
|
||||
}
|
||||
let path = PathData::from_bezpath(&BezPath::from_svg("M0 10 L40 90").unwrap());
|
||||
let faces = revolve(&path, path.bounds().unwrap(), options()).unwrap();
|
||||
assert_eq!(caps(&faces).count(), 1, "only the nonzero endpoint needs a cap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curved_compound_profiles_caps_remain_finite_shaded_and_expandable() {
|
||||
let path = PathData::from_bezpath(&BezPath::from_svg("M40 10 C70 30 20 60 50 90 M30 110 L50 150").unwrap());
|
||||
let bounds = Rect::new(0.0, 10.0, 70.0, 150.0);
|
||||
let faces =
|
||||
revolve(&path, bounds, Revolve { rotation_x: 35.0, rotation_y: 27.0, perspective: perspective_from_strength(0.8), shade: true, ..options() })
|
||||
.unwrap();
|
||||
assert_eq!(caps(&faces).count(), 4);
|
||||
assert!(caps(&faces).all(|face| face.brightness.is_finite() && (0.0..=1.0).contains(&face.brightness)));
|
||||
assert!(!visible_faces(&faces).unwrap().is_empty());
|
||||
for sp in &path.subpaths {
|
||||
let single = PathData::new(vec![sp.clone()]);
|
||||
let reversed = PathData::from_bezpath(&single.to_bezpath().reverse_subpaths());
|
||||
let a = revolve(&single, bounds, options()).unwrap();
|
||||
let b = revolve(&reversed, bounds, options()).unwrap();
|
||||
assert!((caps(&a).map(area).sum::<f64>() - caps(&b).map(area).sum::<f64>()).abs() < 1e-8);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_share_surface_limits_and_malformed_inputs_stay_bounded() {
|
||||
let (path, bounds) = cylinder();
|
||||
let faces = revolve(&path, bounds, Revolve { segments: usize::MAX, angle: f64::NAN, offset: f64::INFINITY, ..options() }).unwrap();
|
||||
assert!(faces.len() <= 384);
|
||||
let mut excessive = BezPath::new();
|
||||
excessive.move_to((40.0, 0.0));
|
||||
for i in 1..=1024 {
|
||||
excessive.line_to((40.0, i as f64));
|
||||
}
|
||||
assert!(revolve(&PathData::from_bezpath(&excessive), Rect::new(0.0, 0.0, 40.0, 1024.0), options()).is_err());
|
||||
assert!(revolve(&PathData::default(), bounds, options()).unwrap().is_empty());
|
||||
// Viewed along the axis, the side walls disappear but the caps still consume
|
||||
// the same face budget. Repeated rings cannot bypass it through degenerate walls.
|
||||
let repeated = PathData::new(vec![path.subpaths[0].clone(); 129]);
|
||||
let o = Revolve { segments: 128, ..options() };
|
||||
assert!(revolve(&repeated, bounds, Revolve { cap_ends: false, ..o }).unwrap().is_empty());
|
||||
assert!(revolve(&repeated, bounds, o).unwrap_err().contains("surface is too complex"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_are_single_curved_paths_and_body_rims_share_their_exact_curves() {
|
||||
use vectorcraft_geom::PathSeg;
|
||||
let (path, bounds) = cylinder();
|
||||
for smooth_shading in [false, true] {
|
||||
for angle in [90.0, 270.0, 360.0] {
|
||||
let faces = revolve(
|
||||
&path,
|
||||
bounds,
|
||||
Revolve {
|
||||
angle,
|
||||
segments: 8,
|
||||
shade: true,
|
||||
smooth_shading,
|
||||
rotation_x: -28.0,
|
||||
perspective: perspective_from_strength(0.7),
|
||||
..options()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let body: Vec<_> = faces.iter().filter(|f| !f.cap).flat_map(|f| f.path().to_bezpath().segments().collect::<Vec<_>>()).collect();
|
||||
for cap in caps(&faces) {
|
||||
let outline = cap.path();
|
||||
assert_eq!(outline.subpaths.len(), 1);
|
||||
assert!(outline.subpaths[0].closed);
|
||||
let mut curves = 0;
|
||||
for segment in outline.to_bezpath().segments() {
|
||||
if let PathSeg::Cubic(c) = segment {
|
||||
curves += 1;
|
||||
let same = |a: Point, b: Point| a.distance(b) < 1e-10;
|
||||
assert!(
|
||||
body.iter().any(|s| match s {
|
||||
PathSeg::Cubic(b) =>
|
||||
(same(c.p0, b.p0) && same(c.p1, b.p1) && same(c.p2, b.p2) && same(c.p3, b.p3))
|
||||
|| (same(c.p0, b.p3) && same(c.p1, b.p2) && same(c.p2, b.p1) && same(c.p3, b.p0)),
|
||||
_ => false,
|
||||
}),
|
||||
"cap and body use the same rim"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(curves >= 8, "the cap contains curves instead of a polygon fan");
|
||||
assert!(cap.gradient.is_none(), "flat cap lighting has no radial seams");
|
||||
}
|
||||
let uncut = visible_faces(&faces[faces.len() - 1..]).unwrap();
|
||||
assert_eq!(uncut[0].path, faces.last().unwrap().path(), "uncut visibility preserves the curved outline");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_centers_follow_the_projected_axis_at_wide_perspective_angles() {
|
||||
let (path, bounds) = cylinder();
|
||||
for perspective in [0.0, perspective_from_strength(0.7), 160.0] {
|
||||
let o = Revolve { perspective, rotation_x: -28.0, rotation_y: 27.0, shade: true, ..options() };
|
||||
let faces = revolve(&path, bounds, o).unwrap();
|
||||
let centers: Vec<_> = caps(&faces).map(|face| face.points[0]).collect();
|
||||
assert_eq!(centers.len(), 2);
|
||||
for center in o.projected_axis(bounds) {
|
||||
assert!(centers.iter().any(|p| p.distance(center) < 1e-10), "cap and axis share the perspective projection");
|
||||
}
|
||||
assert!(faces.iter().flat_map(|face| &face.points).all(|p| p.x.is_finite() && p.y.is_finite()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capped_visibility_matches_painted_surfaces_above_below_and_for_major_sectors() {
|
||||
use vectorcraft_geom::Shape;
|
||||
let (path, bounds) = cylinder();
|
||||
for edge in [Edge::Left, Edge::Right] {
|
||||
for (angle, rotation_x, perspective, smooth_shading) in
|
||||
[(360.0, -28.0, 0.0, false), (360.0, 28.0, perspective_from_strength(0.7), true), (270.0, -32.0, perspective_from_strength(0.7), true)]
|
||||
{
|
||||
let faces = revolve(
|
||||
&path,
|
||||
bounds,
|
||||
Revolve { angle, edge, offset: 20.0, shade: true, rotation_x, rotation_y: 17.0, perspective, smooth_shading, ..options() },
|
||||
)
|
||||
.unwrap();
|
||||
let visible = visible_faces(&faces).unwrap();
|
||||
let all: Vec<_> = faces.iter().map(|f| (f.path().to_bezpath(), f.depth)).collect();
|
||||
let cut: Vec<_> = visible.iter().map(|f| (f.path.to_bezpath(), f.depth)).collect();
|
||||
for x in 0..40 {
|
||||
for y in 0..40 {
|
||||
let p = Point::new(-100.0 + (x as f64 + 0.337) * 6.0, -50.0 + (y as f64 + 0.413) * 6.0);
|
||||
let expected = all.iter().rev().find(|(bp, _)| bp.winding(p) != 0).map(|(_, depth)| depth);
|
||||
let actual = cut.iter().rev().find(|(bp, _)| bp.winding(p) != 0).map(|(_, depth)| depth);
|
||||
assert_eq!(actual, expected, "{edge:?}, {angle}, {rotation_x}, {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
use super::*;
|
||||
|
||||
fn path(d: &str) -> PathData {
|
||||
PathData::from_bezpath(&BezPath::from_svg(d).unwrap())
|
||||
}
|
||||
|
||||
fn value(f: &Face, p: Point) -> f64 {
|
||||
f.gradient.map_or(f.brightness, |g| g.at(p))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_matches_three_vertex_values_and_shared_diagonal() {
|
||||
let [a, b, c, d] = [Point::new(10.0, 20.0), Point::new(90.0, 30.0), Point::new(80.0, 95.0), Point::new(0.0, 85.0)];
|
||||
let g = ShadeGradient::triangle(&[a, b, c], &[0.2, 0.9, 0.6]).unwrap();
|
||||
let h = ShadeGradient::triangle(&[a, c, d], &[0.2, 0.6, 0.4]).unwrap();
|
||||
for (p, expected) in [(a, 0.2), (b, 0.9), (c, 0.6)] {
|
||||
assert!((g.at(p) - expected).abs() < 1e-12);
|
||||
}
|
||||
for t in [0.0, 0.1, 0.5, 0.9, 1.0] {
|
||||
assert!((g.at(a.lerp(c, t)) - h.at(a.lerp(c, t))).abs() < 1e-12);
|
||||
}
|
||||
assert!(ShadeGradient::triangle(&[a, a, c], &[0.1, 0.5, 0.9]).is_none());
|
||||
assert!(ShadeGradient::triangle(&[a, b, c], &[0.4; 3]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curved_profile_shares_lighting_across_radial_and_curve_sample_edges() {
|
||||
let p = path("M40 0 C70 20 70 60 40 80");
|
||||
let o = Revolve { smooth_shading: true, cap_ends: false, offset: 20.0, angle: 90.0, rotation_y: -45.0, segments: 16, ..Default::default() };
|
||||
let faces = revolve(&p, p.bounds().unwrap(), o).unwrap();
|
||||
assert!(faces.iter().filter(|f| f.gradient.is_some()).count() > 60);
|
||||
let mut edges = 0;
|
||||
for (i, a) in faces.iter().enumerate() {
|
||||
for b in faces.iter().skip(i + 1) {
|
||||
let shared: Vec<_> = a.points.iter().filter(|p| b.points.iter().any(|q| p.distance(*q) < 1e-8)).copied().collect();
|
||||
if let [p, q] = shared.as_slice() {
|
||||
edges += 1;
|
||||
for t in [0.0, 0.5, 1.0] {
|
||||
let p = p.lerp(*q, t);
|
||||
assert!((value(a, p) - value(b, p)).abs() < 1e-8, "lighting seam at {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(edges > 100, "test both curve sample boundaries and triangle diagonals");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_corners_and_caps_retain_hard_edges() {
|
||||
let p = path("M40 0 L40 40 L70 40");
|
||||
let o = Revolve { smooth_shading: true, offset: 30.0, angle: 90.0, rotation_y: -45.0, rotation_x: -20.0, segments: 16, ..Default::default() };
|
||||
let faces = revolve(&p, p.bounds().unwrap(), o).unwrap();
|
||||
let mut hard = false;
|
||||
for a in &faces {
|
||||
for b in &faces {
|
||||
let shared: Vec<_> = a.points.iter().filter(|p| b.points.iter().any(|q| p.distance(*q) < 1e-8)).copied().collect();
|
||||
if shared.len() == 2 && shared.iter().any(|p| (value(a, *p) - value(b, *p)).abs() > 0.1) {
|
||||
hard = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(hard, "the profile's ninety-degree corner is not rounded by shading");
|
||||
let p = path("M40 0 L40 80");
|
||||
let flat = revolve(&p, p.bounds().unwrap(), Revolve { cap_ends: true, smooth_shading: false, ..o }).unwrap();
|
||||
let smooth = revolve(&p, p.bounds().unwrap(), Revolve { cap_ends: true, ..o }).unwrap();
|
||||
let caps: Vec<_> = flat.iter().filter(|f| f.cap).collect();
|
||||
assert_eq!(caps.len(), 2);
|
||||
assert!(caps.into_iter().all(|cap| smooth.contains(cap)), "caps keep their exact flat shading");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_vertices_use_the_shared_soft_light_and_back_light() {
|
||||
let p = path("M40 0 L40 80");
|
||||
let bounds = p.bounds().unwrap();
|
||||
for behind in [false, true] {
|
||||
let o = Revolve {
|
||||
offset: 30.0,
|
||||
angle: 90.0,
|
||||
rotation_x: -20.0,
|
||||
rotation_y: -30.0,
|
||||
cap_ends: false,
|
||||
light_rotation: 0.0,
|
||||
light_height: 30.0,
|
||||
light_behind: behind,
|
||||
light_softness: 0.8,
|
||||
ambient: 0.15,
|
||||
..Default::default()
|
||||
};
|
||||
let faces = revolve(&p, bounds, o).unwrap();
|
||||
let vertex = o.rotate(V3(30.0, -40.0, 0.0));
|
||||
let point = Point::new(10.0 + vertex.0, 40.0 + vertex.1);
|
||||
let normal = o.rotate(V3(1.0, 0.0, 0.0));
|
||||
let [x, y, z] = o.light_direction();
|
||||
let expected = (0.15 + 0.8 * ((normal.dot(V3(x, y, z)) + 0.8) / 1.8).max(0.0)).clamp(0.0, 1.0);
|
||||
let samples: Vec<_> = faces.iter().filter(|face| face.points.iter().any(|p| p.distance(point) < 1e-10)).collect();
|
||||
assert!(!samples.is_empty());
|
||||
for face in samples {
|
||||
assert!((value(face, point) - expected).abs() < 1e-10, "smooth shading preserves Softness and the light's side");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unshaded_legacy_degenerate_and_high_complexity_surfaces_stay_bounded() {
|
||||
let p = path("M40 0 L40 80");
|
||||
let b = p.bounds().unwrap();
|
||||
let o = Revolve { offset: 30.0, ..Default::default() };
|
||||
assert!(revolve(&p, b, o).unwrap().iter().any(|f| f.gradient.is_some()));
|
||||
assert!(revolve(&p, b, Revolve { smooth_shading: false, ..o }).unwrap().iter().all(|f| f.gradient.is_none()));
|
||||
assert_eq!(revolve(&p, b, Revolve { shade: false, ..o }).unwrap(), revolve(&p, b, Revolve { shade: false, smooth_shading: true, ..o }).unwrap());
|
||||
for edge in [Edge::Left, Edge::Right] {
|
||||
for perspective in [0.0, perspective_from_strength(1.0), 160.0] {
|
||||
let o = Revolve { smooth_shading: true, edge, perspective, rotation_x: -35.0, ..o };
|
||||
let faces = revolve(&p, b, o).unwrap();
|
||||
assert!(faces.iter().all(|f| f.points.iter().all(|p| value(f, *p).is_finite() && (0.0..=1.0).contains(&value(f, *p)))));
|
||||
assert!(visible_faces(&faces).is_ok());
|
||||
}
|
||||
}
|
||||
assert!(revolve(&p, b, Revolve { smooth_shading: true, angle: 0.0, ..o }).unwrap().is_empty());
|
||||
let mut p = path("M40 0");
|
||||
p.subpaths[0].anchors.extend((1..400).map(|i| vectorcraft_geom::Anchor::corner(Point::new(40.0 + (i % 2) as f64, i as f64))));
|
||||
assert!(revolve(&p, p.bounds().unwrap(), Revolve { smooth_shading: true, segments: 128, offset: 30.0, ..o }).is_err());
|
||||
}
|
||||
@@ -4,12 +4,16 @@
|
||||
|
||||
use vectorcraft_geom::{PathData, Point, Rect, SubPath};
|
||||
|
||||
use crate::{Face, MAX_FACES};
|
||||
use crate::{Face, MAX_FACES, ShadeGradient};
|
||||
#[path = "visibility_parts.rs"]
|
||||
mod parts;
|
||||
|
||||
const GRID: usize = 32;
|
||||
const MAX_GRID_REFS: usize = 2_000_000;
|
||||
const MAX_WORK: usize = 16_000_000;
|
||||
const MAX_FRAGMENTS: usize = 256;
|
||||
// Curved boundaries may begin with several convex pieces. Their total work and
|
||||
// anchors still share the global limits used for ordinary projected faces.
|
||||
const MAX_FRAGMENTS: usize = 1024;
|
||||
const MAX_OUTPUT_POINTS: usize = 262_144;
|
||||
|
||||
/// The visible portions of one source face, with its original shading and paint order.
|
||||
@@ -18,6 +22,7 @@ pub struct VisibleFace {
|
||||
pub path: PathData,
|
||||
pub brightness: f64,
|
||||
pub depth: f64,
|
||||
pub gradient: Option<ShadeGradient>,
|
||||
}
|
||||
|
||||
struct Polygon {
|
||||
@@ -81,6 +86,34 @@ fn subtract(points: Vec<Point>, cover: &Polygon, epsilon: f64, work: &mut usize)
|
||||
if !overlaps(bounds(&points), cover.bounds) {
|
||||
return Ok(vec![points]);
|
||||
}
|
||||
// Bounding boxes of neighbouring curved pieces overlap even when the pieces
|
||||
// only share an edge. Reject separated convex polygons before cutting them,
|
||||
// so irrelevant covers cannot subdivide a face into hundreds of fragments.
|
||||
let mut separated = false;
|
||||
'edges: for (polygon, other) in [(&cover.points, &points), (&points, &cover.points)] {
|
||||
for (a, b) in polygon.iter().zip(polygon.iter().cycle().skip(1)).take(polygon.len()) {
|
||||
*work += other.len();
|
||||
if *work > MAX_WORK {
|
||||
return Err("Revolve visibility exceeds its geometry work limit".into());
|
||||
}
|
||||
if other.iter().all(|p| (*b - *a).cross(*p - *a) <= epsilon * b.distance(*a)) {
|
||||
separated = true;
|
||||
break 'edges;
|
||||
}
|
||||
}
|
||||
}
|
||||
if separated {
|
||||
return Ok(vec![points]);
|
||||
}
|
||||
if cover
|
||||
.points
|
||||
.iter()
|
||||
.zip(cover.points.iter().cycle().skip(1))
|
||||
.take(cover.points.len())
|
||||
.all(|(a, b)| points.iter().all(|p| (*b - *a).cross(*p - *a) >= -epsilon * b.distance(*a)))
|
||||
{
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let mut remaining = points;
|
||||
let mut visible = vec![];
|
||||
for (a, b) in cover.points.iter().zip(cover.points.iter().cycle().skip(1)).take(cover.points.len()) {
|
||||
@@ -138,13 +171,27 @@ pub fn visible_faces(faces: &[Face]) -> Result<Vec<VisibleFace>, String> {
|
||||
if faces.len() > MAX_FACES {
|
||||
return Err("Too many Revolve faces for visibility evaluation".into());
|
||||
}
|
||||
let count = |n: usize, len: usize| n.checked_add(len).filter(|&n| n <= MAX_OUTPUT_POINTS).ok_or("Revolve visibility has too many input anchors");
|
||||
faces.iter().map(|f| f.points.len()).try_fold(0, count)?;
|
||||
faces.iter().filter_map(|f| f.visibility_pieces.as_ref()).flatten().map(Vec::len).try_fold(0, count)?;
|
||||
faces.iter().filter_map(|f| f.outline.as_ref()).flat_map(|p| &p.subpaths).map(|p| p.anchors.len()).try_fold(0, count)?;
|
||||
if faces.iter().any(|f| {
|
||||
f.points.len() > 4
|
||||
f.points.len() > 1026
|
||||
|| !f.brightness.is_finite()
|
||||
|| !f.depth.is_finite()
|
||||
|| f.gradient.is_some_and(|g| [g.start.x, g.start.y, g.end.x, g.end.y, g.brightness[0], g.brightness[1]].iter().any(|v| !v.is_finite()))
|
||||
|| f.points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite() || p.x.abs().max(p.y.abs()) > 1e8)
|
||||
|| f.visibility_pieces.as_ref().is_some_and(|pieces| {
|
||||
pieces.iter().any(|p| p.len() > 1026 || p.iter().any(|p| !p.x.is_finite() || !p.y.is_finite() || p.x.abs().max(p.y.abs()) > 1e8))
|
||||
})
|
||||
|| f.outline.as_ref().is_some_and(|p| {
|
||||
p.subpaths
|
||||
.iter()
|
||||
.flat_map(|s| &s.anchors)
|
||||
.any(|a| [a.p, a.h_in, a.h_out].iter().any(|p| !p.x.is_finite() || !p.y.is_finite() || p.x.abs().max(p.y.abs()) > 1e8))
|
||||
})
|
||||
}) {
|
||||
return Err("Revolve visibility requires finite projected convex faces".into());
|
||||
return Err("Revolve visibility requires finite projected faces".into());
|
||||
}
|
||||
let all = faces.iter().flat_map(|f| &f.points).copied().collect::<Vec<_>>();
|
||||
let total = bounds(&all);
|
||||
@@ -152,33 +199,27 @@ pub fn visible_faces(faces: &[Face]) -> Result<Vec<VisibleFace>, String> {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let epsilon = (total.width().max(total.height()) * 1e-10).clamp(1e-8, 1e-3);
|
||||
let polygons: Vec<_> = faces
|
||||
let mut work = 0;
|
||||
let polygons: Vec<Vec<Polygon>> = faces
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let points = clean(f.points.clone(), epsilon);
|
||||
Polygon { bounds: bounds(&points), points }
|
||||
})
|
||||
.collect();
|
||||
// Generated quads are convex. Validate the public helper rather than accepting concave
|
||||
// callers and silently deleting geometry they can see.
|
||||
for p in &polygons {
|
||||
if p.points.iter().enumerate().any(|(i, a)| {
|
||||
let Some(b) = p.points.get((i + 1) % p.points.len()) else { return false };
|
||||
let Some(c) = p.points.get((i + 2) % p.points.len()) else { return false };
|
||||
(*b - *a).cross(*c - *b) < -epsilon * b.distance(*a).max(1.0)
|
||||
}) {
|
||||
return Err("Revolve visibility requires convex projected faces".into());
|
||||
}
|
||||
}
|
||||
.map(|f| Ok(parts::pieces(f, epsilon, &mut work)?.into_iter().map(|points| Polygon { bounds: bounds(&points), points }).collect()))
|
||||
.collect::<Result<_, String>>()?;
|
||||
let covers: Vec<_> = polygons.iter().flatten().collect();
|
||||
let mut index = Index { cells: vec![vec![]; GRID * GRID], bounds: total, refs: 0 };
|
||||
let mut seen = vec![usize::MAX; faces.len()];
|
||||
let (mut out, mut work, mut output_points) = (vec![], 0, 0);
|
||||
for (source, (face, polygon)) in faces.iter().zip(&polygons).enumerate().rev() {
|
||||
if polygon.points.is_empty() {
|
||||
let mut seen = vec![usize::MAX; covers.len()];
|
||||
let (mut out, mut output_points) = (vec![], 0);
|
||||
let mut cover_end = covers.len();
|
||||
for (source, (face, pieces)) in faces.iter().zip(&polygons).enumerate().rev() {
|
||||
let cover_start = cover_end.saturating_sub(pieces.len());
|
||||
let source_covers = cover_start..cover_end;
|
||||
cover_end = cover_start;
|
||||
if pieces.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut fragments = vec![polygon.points.clone()];
|
||||
'covers: for tile in index.tiles(polygon.bounds) {
|
||||
let source_bounds = bounds(&face.points);
|
||||
let mut fragments: Vec<_> = pieces.iter().map(|p| p.points.clone()).collect();
|
||||
let mut clipped = false;
|
||||
'covers: for tile in index.tiles(source_bounds) {
|
||||
for &candidate in index.cells.get(tile).into_iter().flatten() {
|
||||
work += 1;
|
||||
if work > MAX_WORK {
|
||||
@@ -188,16 +229,17 @@ pub fn visible_faces(faces: &[Face]) -> Result<Vec<VisibleFace>, String> {
|
||||
&& *mark != source
|
||||
{
|
||||
*mark = source;
|
||||
if let Some(cover) = polygons.get(candidate)
|
||||
&& overlaps(polygon.bounds, cover.bounds)
|
||||
if let Some(cover) = covers.get(candidate)
|
||||
&& overlaps(source_bounds, cover.bounds)
|
||||
{
|
||||
let mut remaining = vec![];
|
||||
for fragment in fragments {
|
||||
remaining.extend(subtract(fragment, cover, epsilon, &mut work)?);
|
||||
for fragment in &fragments {
|
||||
remaining.extend(subtract(fragment.clone(), cover, epsilon, &mut work)?);
|
||||
if remaining.len() > MAX_FRAGMENTS {
|
||||
return Err("Revolve visibility creates too many fragments".into());
|
||||
}
|
||||
}
|
||||
clipped |= remaining != fragments;
|
||||
fragments = remaining;
|
||||
if fragments.is_empty() {
|
||||
break 'covers;
|
||||
@@ -213,11 +255,15 @@ pub fn visible_faces(faces: &[Face]) -> Result<Vec<VisibleFace>, String> {
|
||||
if output_points > MAX_OUTPUT_POINTS {
|
||||
return Err("Revolve visibility creates too many vector anchors".into());
|
||||
}
|
||||
let path = PathData::new(fragments.into_iter().map(|p| SubPath::polyline(&p, true)).collect());
|
||||
out.push(VisibleFace { path, brightness: face.brightness, depth: face.depth });
|
||||
let path = if !clipped { face.path() } else { PathData::new(fragments.into_iter().map(|p| SubPath::polyline(&p, true)).collect()) };
|
||||
out.push(VisibleFace { path, brightness: face.brightness, depth: face.depth, gradient: face.gradient });
|
||||
// A completely covered face need not occupy the grid. Its coverage already belongs
|
||||
// to nearer faces; this keeps repeated overlapping surfaces bounded in practice.
|
||||
index.insert(polygon.bounds, source)?;
|
||||
for candidate in source_covers {
|
||||
if let Some(cover) = covers.get(candidate) {
|
||||
index.insert(cover.bounds, candidate)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.reverse();
|
||||
Ok(out)
|
||||
@@ -234,6 +280,10 @@ mod tests {
|
||||
points: vec![Point::new(rect.x0, rect.y0), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)],
|
||||
depth: brightness,
|
||||
brightness,
|
||||
gradient: None,
|
||||
outline: None,
|
||||
cap: false,
|
||||
visibility_pieces: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Bounded convex pieces for continuous caps and curved endpoint faces.
|
||||
|
||||
use super::{Face, MAX_WORK, Point, area, clean};
|
||||
|
||||
fn convex(points: &[Point], epsilon: f64) -> bool {
|
||||
points.iter().enumerate().all(|(i, a)| {
|
||||
let Some(b) = points.get((i + 1) % points.len()) else { return true };
|
||||
let Some(c) = points.get((i + 2) % points.len()) else { return true };
|
||||
(*b - *a).cross(*c - *b) >= -epsilon * b.distance(*a).max(1.0)
|
||||
})
|
||||
}
|
||||
|
||||
fn triangulate(mut points: Vec<Point>, epsilon: f64, work: &mut usize, depth: usize) -> Result<Vec<Vec<Point>>, String> {
|
||||
if points.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
if convex(&points, epsilon) {
|
||||
return Ok(vec![points]);
|
||||
}
|
||||
// At a grazing view a curved rim can fold across a triangle's diagonal.
|
||||
// Split that crossing before ear clipping; its two lobes both have nonzero
|
||||
// winding and must remain visible. Bound splitting for public helper inputs.
|
||||
for (i, (a, b)) in points.iter().zip(points.iter().cycle().skip(1)).take(points.len()).enumerate() {
|
||||
for j in i + 2..points.len() {
|
||||
if (j + 1) % points.len() == i {
|
||||
continue;
|
||||
}
|
||||
*work += 1;
|
||||
if *work > MAX_WORK {
|
||||
return Err("Revolve visibility exceeds its geometry work limit".into());
|
||||
}
|
||||
let Some(c) = points.get(j) else { continue };
|
||||
let Some(d) = points.get((j + 1) % points.len()) else { continue };
|
||||
let (ab, cd) = (*b - *a, *d - *c);
|
||||
let denominator = ab.cross(cd);
|
||||
if denominator.abs() <= epsilon * epsilon {
|
||||
continue;
|
||||
}
|
||||
let (t, u) = ((*c - *a).cross(cd) / denominator, (*c - *a).cross(ab) / denominator);
|
||||
if t > 1e-9 && t < 1.0 - 1e-9 && u > 1e-9 && u < 1.0 - 1e-9 {
|
||||
if depth >= 8 {
|
||||
return Err("Revolve visibility has too many boundary crossings".into());
|
||||
}
|
||||
let intersection = a.lerp(*b, t);
|
||||
let first = std::iter::once(intersection).chain(points.get(i + 1..=j).unwrap_or_default().iter().copied()).collect();
|
||||
let second = std::iter::once(intersection).chain(points.iter().cycle().skip(j + 1).take(points.len() + i - j).copied()).collect();
|
||||
let mut out = triangulate(clean(first, epsilon), epsilon, work, depth + 1)?;
|
||||
out.extend(triangulate(clean(second, epsilon), epsilon, work, depth + 1)?);
|
||||
return Ok(out);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
while points.len() > 3 {
|
||||
let mut ear = None;
|
||||
for (i, b) in points.iter().enumerate() {
|
||||
let Some(a) = points.get((i + points.len() - 1) % points.len()) else { continue };
|
||||
let Some(c) = points.get((i + 1) % points.len()) else { continue };
|
||||
if (*b - *a).cross(*c - *b) <= epsilon * epsilon {
|
||||
continue;
|
||||
}
|
||||
let mut blocked = false;
|
||||
for p in &points {
|
||||
*work += 1;
|
||||
if *work > MAX_WORK {
|
||||
return Err("Revolve visibility exceeds its geometry work limit".into());
|
||||
}
|
||||
if p != a
|
||||
&& p != b
|
||||
&& p != c
|
||||
&& (*b - *a).cross(*p - *a) >= -epsilon * epsilon
|
||||
&& (*c - *b).cross(*p - *b) >= -epsilon * epsilon
|
||||
&& (*a - *c).cross(*p - *c) >= -epsilon * epsilon
|
||||
{
|
||||
blocked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !blocked {
|
||||
ear = Some((i, vec![*a, *b, *c]));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some((i, triangle)) = ear else { return Err("Revolve visibility cannot split the projected boundary".into()) };
|
||||
out.push(triangle);
|
||||
points.remove(i);
|
||||
}
|
||||
if area(&points).abs() > epsilon * epsilon {
|
||||
out.push(points);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(super) fn pieces(face: &Face, epsilon: f64, work: &mut usize) -> Result<Vec<Vec<Point>>, String> {
|
||||
if let Some(strips) = &face.visibility_pieces {
|
||||
let mut out = vec![];
|
||||
for strip in strips {
|
||||
out.extend(triangulate(clean(strip.clone(), epsilon), epsilon, work, 0)?);
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
if !face.cap {
|
||||
return triangulate(clean(face.points.clone(), epsilon), epsilon, work, 0);
|
||||
}
|
||||
let Some((center, ring)) = face.points.split_first() else { return Ok(vec![]) };
|
||||
let mut out = vec![];
|
||||
// At least 512 samples per full circle makes 128 edges at most a quarter
|
||||
// turn. These sectors are convex even when the complete sweep exceeds 180°.
|
||||
for start in (0..ring.len().saturating_sub(1)).step_by(128) {
|
||||
let end = (start + 128).min(ring.len().saturating_sub(1));
|
||||
let Some(arc) = ring.get(start..=end) else { continue };
|
||||
let points = clean(std::iter::once(*center).chain(arc.iter().copied()).collect(), epsilon);
|
||||
if !convex(&points, epsilon) {
|
||||
return Err("Revolve visibility requires convex cap sectors".into());
|
||||
}
|
||||
if !points.is_empty() {
|
||||
out.push(points);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -90,6 +90,18 @@ fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
});
|
||||
number(ui, d, "offset", tl!("Offset"), (0.0, 100000.0), "", 1, Some(unit));
|
||||
ui.horizontal(|ui| {
|
||||
let mut cap = d.bool("cap");
|
||||
let tip = tl!("Close the circular ends of open profiles. Partial sweeps keep their cut sides open.");
|
||||
if widgets::check_tip(ui, c3("Cap"), cap, true, tip) {
|
||||
cap = !cap;
|
||||
d.fields.insert("cap".into(), json!(cap));
|
||||
}
|
||||
ui.add_space((ui.available_width() - 24.0).max(0.0));
|
||||
if reset_dot(ui, "cap", !cap, "true") {
|
||||
d.fields.insert("cap".into(), json!(true));
|
||||
}
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
heading(ui, tl!("View"));
|
||||
for (key, name) in [("rotationX", tl!("Rotation X")), ("rotationY", tl!("Rotation Y")), ("rotationZ", tl!("Rotation Z"))] {
|
||||
|
||||
@@ -91,7 +91,8 @@ fn values_labels_sliders_and_reset_dots_change_the_live_effect_in_one_undo_step(
|
||||
h.settle();
|
||||
assert!(!h.rect("Advanced").is_negative(), "Advanced is visible without scrolling in a normal window");
|
||||
let panel = h.ctx.memory(|m| m.area_rect(Id::new(("dialog", "effect")).with("revolve"))).unwrap();
|
||||
assert!(panel.width() < 420.0 && panel.height() < 540.0, "compact controls: {panel:?}");
|
||||
// Cap adds one 28-point row to the shared dialog's previous 540-point bound.
|
||||
assert!(panel.width() < 420.0 && panel.height() < 570.0, "compact controls with Cap: {panel:?}");
|
||||
let label = h.rect("Rotation X");
|
||||
h.drag(pos2(label.left() + LABEL_W + 6.0 + VALUE_W / 2.0, label.center().y), vec2(40.0, 0.0));
|
||||
assert!(h.value("rotationX") > 0.0, "the value itself drags");
|
||||
@@ -166,6 +167,54 @@ fn lighting_tab_shows_light_fields_and_keeps_the_dialog_compact() {
|
||||
assert!(!h.rect("Rotation X").is_negative());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_smooth_shading_previews_saves_and_respects_cancel_and_one_step_undo() {
|
||||
for confirm in [false, true] {
|
||||
let mut h = Harness::new(vec2(1280.0, 900.0));
|
||||
let before = h.app.session.doc().unwrap().doc.clone();
|
||||
h.settle();
|
||||
h.click(h.rect("Lighting").center());
|
||||
assert!(!h.texts.iter().any(|(text, _)| text == "Smooth shading"));
|
||||
assert!(!h.app.ui.dialog.as_ref().unwrap().fields.contains_key("smoothShading"));
|
||||
let art = |h: &Harness| {
|
||||
let state = h.app.session.doc().unwrap();
|
||||
vectorcraft_effects::revolve_art(state.doc.node(state.selection.objects[0]).unwrap()).unwrap()
|
||||
};
|
||||
let gradients = |n: &vectorcraft_doc::Node| {
|
||||
let mut count = 0;
|
||||
n.walk(&mut |face| {
|
||||
count += face.appearance.items.iter().filter(|item| {
|
||||
matches!(item, vectorcraft_doc::AppearanceItem::Fill(fill) if matches!(fill.paint, vectorcraft_doc::color::Paint::Gradient(_)))
|
||||
}).count();
|
||||
});
|
||||
count
|
||||
};
|
||||
assert!(gradients(&art(&h)) > 0, "default rendering uses vector gradients");
|
||||
h.click(h.rect("Shading").center());
|
||||
assert_eq!(gradients(&art(&h)), 0, "Shading still controls all lighting");
|
||||
h.click(h.response(("revolve-reset", "shade")).center());
|
||||
assert!(gradients(&art(&h)) > 0);
|
||||
h.drag(h.rect("Height").center(), vec2(24.0, 0.0));
|
||||
h.app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(false));
|
||||
h.settle();
|
||||
assert_eq!(h.app.session.doc().unwrap().doc, before);
|
||||
h.app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(true));
|
||||
h.settle();
|
||||
if confirm {
|
||||
dialogs::confirm(&mut h.app).unwrap();
|
||||
let state = h.app.session.doc().unwrap();
|
||||
let node = state.doc.node(state.selection.objects[0]).unwrap();
|
||||
let loaded = vectorcraft_format::load(&vectorcraft_format::save(&state.doc, false)).unwrap();
|
||||
assert!(!node.appearance.effects[0].params.as_object().unwrap().contains_key("smoothShading"));
|
||||
assert_eq!(vectorcraft_effects::revolve_art(loaded.node(node.id).unwrap()), vectorcraft_effects::revolve_art(node));
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
} else {
|
||||
dialogs::cancel(&mut h.app);
|
||||
}
|
||||
assert_eq!(h.app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_visibility_option_is_saved_resettable_and_leaves_preview_geometry_complete() {
|
||||
let mut h = Harness::new(vec2(1280.0, 1000.0));
|
||||
@@ -257,3 +306,43 @@ fn extrude_bevel_options_enable_with_a_bevel_shape() {
|
||||
assert!(h.ctx.read_response(Id::new(("revolve-reset", "bevelWidth"))).unwrap().enabled());
|
||||
assert_eq!(h.response(("revolve-reset", "bevelWidth")), width);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_checkbox_previews_resets_and_respects_cancel_and_one_step_undo() {
|
||||
for confirm in [false, true] {
|
||||
let mut h = Harness::new(vec2(1280.0, 1000.0));
|
||||
let before = h.app.session.doc().unwrap().doc.clone();
|
||||
let dialog = h.app.ui.dialog.as_mut().unwrap();
|
||||
dialog.fields.insert("offset".into(), json!(20));
|
||||
dialog.fields.insert("rotationX".into(), json!(-35));
|
||||
h.settle();
|
||||
let art = |h: &Harness| {
|
||||
let state = h.app.session.doc().unwrap();
|
||||
vectorcraft_effects::revolve_art(state.doc.node(state.selection.objects[0]).unwrap()).unwrap()
|
||||
};
|
||||
assert!(h.app.ui.dialog.as_ref().unwrap().bool("cap"), "Cap starts on");
|
||||
let capped = art(&h);
|
||||
h.click(h.rect("Cap").center());
|
||||
assert!(!h.app.ui.dialog.as_ref().unwrap().bool("cap"));
|
||||
assert_ne!(art(&h), capped);
|
||||
h.click(h.response(("revolve-reset", "cap")).center());
|
||||
assert!(h.app.ui.dialog.as_ref().unwrap().bool("cap"));
|
||||
assert_eq!(art(&h), capped);
|
||||
h.click(h.rect("Cap").center());
|
||||
h.click(h.response("revolve-reset-all").center());
|
||||
assert!(h.app.ui.dialog.as_ref().unwrap().bool("cap"));
|
||||
h.click(h.rect("Cap").center());
|
||||
if confirm {
|
||||
dialogs::confirm(&mut h.app).unwrap();
|
||||
let state = h.app.session.doc().unwrap();
|
||||
assert_eq!(state.doc.node(state.selection.objects[0]).unwrap().appearance.effects[0].params["cap"], json!(false));
|
||||
let loaded = vectorcraft_format::load(&vectorcraft_format::save(&state.doc, false)).unwrap();
|
||||
assert_eq!(loaded.node(state.selection.objects[0]).unwrap().appearance.effects[0].params["cap"], json!(false));
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
} else {
|
||||
dialogs::cancel(&mut h.app);
|
||||
}
|
||||
assert_eq!(h.app.session.doc().unwrap().doc, before);
|
||||
assert!(!h.app.session.in_interaction());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4808,3 +4808,6 @@ mark-style Japanese Japonské
|
||||
Softness Měkkost
|
||||
Ambient Light Okolní světlo
|
||||
Light behind the object Světlo za objektem
|
||||
|
||||
# Revolve cap description
|
||||
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Uzavře kruhové konce otevřených profilů. Boční řezy částečného otočení zůstávají otevřené.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4335,3 +4335,6 @@ theme Appearance Erscheinungsbild
|
||||
Softness Weichheit
|
||||
Ambient Light Umgebungslicht
|
||||
Light behind the object Licht hinter dem Objekt
|
||||
|
||||
# Revolve cap description
|
||||
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Schließt die kreisförmigen Enden offener Profile. Bei einer Teildrehung bleiben die Schnittseiten offen.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4749,3 +4749,6 @@ mark-style Japanese Japonesas
|
||||
Softness Suavidad
|
||||
Ambient Light Luz ambiental
|
||||
Light behind the object Luz detrás del objeto
|
||||
|
||||
# Revolve cap description
|
||||
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Cierra los extremos circulares de los perfiles abiertos. Los giros parciales mantienen abiertos sus lados de corte.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4808,3 +4808,6 @@ theme Appearance Apparence
|
||||
Softness Douceur
|
||||
Ambient Light Lumière ambiante
|
||||
Light behind the object Lumière derrière l'objet
|
||||
|
||||
# Revolve cap description
|
||||
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Ferme les extrémités circulaires des profils ouverts. Les rotations partielles conservent leurs faces de coupe ouvertes.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4775,3 +4775,6 @@ imageTrace Outline Körvonal
|
||||
Softness Lágyság
|
||||
Ambient Light Környezeti fény
|
||||
Light behind the object Fény az objektum mögött
|
||||
|
||||
# Revolve cap description
|
||||
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Nyitott profilok kör alakú végeinek lezárása. Részleges körforgatás esetén a vágott oldalak nyitva maradnak.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4380,3 +4380,6 @@ lighting Light Luce
|
||||
Softness Morbidezza
|
||||
Ambient Light Luce ambientale
|
||||
Light behind the object Luce dietro l'oggetto
|
||||
|
||||
# Revolve cap description
|
||||
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Chiude le estremità circolari dei profili aperti. Le rotazioni parziali mantengono aperti i lati di taglio.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4374,3 +4374,6 @@ lighting Light ライト
|
||||
Softness 柔らかさ
|
||||
Ambient Light 環境光
|
||||
Light behind the object オブジェクトの背後のライト
|
||||
|
||||
# Revolve cap description
|
||||
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. 開いたプロファイルの円形の端を閉じます。部分的な回転では切断面は開いたままです。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4344,3 +4344,6 @@ lighting Light 광원
|
||||
Softness 부드러움
|
||||
Ambient Light 주변광
|
||||
Light behind the object 개체 뒤의 조명
|
||||
|
||||
# Revolve cap description
|
||||
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. 열린 프로파일의 원형 끝을 닫습니다. 부분 회전에서는 절단 측면이 열린 상태로 유지됩니다.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4651,3 +4651,6 @@ mark-style Japanese Japońskie
|
||||
Softness Miękkość
|
||||
Ambient Light Światło otoczenia
|
||||
Light behind the object Światło za obiektem
|
||||
|
||||
# Revolve cap description
|
||||
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Zamknij okrągłe końce otwartych profili. Przy częściowym obrocie boki przekroju pozostają otwarte.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3763,3 +3763,6 @@ axis Both Ambos
|
||||
Softness Suavidade
|
||||
Ambient Light Luz ambiente
|
||||
Light behind the object Luz atrás do objeto
|
||||
|
||||
# Revolve cap description
|
||||
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Fecha as extremidades circulares dos perfis abertos. Giros parciais mantêm as faces de corte abertas.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4376,3 +4376,6 @@ lighting Light Свет
|
||||
Softness Мягкость
|
||||
Ambient Light Окружающий свет
|
||||
Light behind the object Свет позади объекта
|
||||
|
||||
# Revolve cap description
|
||||
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Закрывает круглые торцы открытых профилей. При неполном вращении боковые срезы остаются открытыми.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4360,3 +4360,6 @@ lighting Light Світло
|
||||
Softness М'якість
|
||||
Ambient Light Навколишнє світло
|
||||
Light behind the object Світло позаду об'єкта
|
||||
|
||||
# Revolve cap description
|
||||
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Закриває круглі торці відкритих профілів. За неповного обертання бічні зрізи залишаються відкритими.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 713 and column 40.
|
@@ -4362,3 +4362,6 @@ theme Appearance Giao diện
|
||||
Softness Độ mềm
|
||||
Ambient Light Ánh sáng môi trường
|
||||
Light behind the object Ánh sáng phía sau đối tượng
|
||||
|
||||
# Revolve cap description
|
||||
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Đóng các đầu tròn của biên dạng mở. Khi xoay một phần, các mặt cắt bên vẫn mở.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3682 and column 10.
|
@@ -3957,3 +3957,6 @@ lighting Light 光源
|
||||
Softness 柔和度
|
||||
Ambient Light 环境光
|
||||
Light behind the object 光源位于对象后方
|
||||
|
||||
# Revolve cap description
|
||||
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. 封闭开放轮廓的圆形端部。部分旋转的切割侧面保持开放。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3741,3 +3741,6 @@ lighting Light 光源
|
||||
Softness 柔和度
|
||||
Ambient Light 環境光
|
||||
Light behind the object 光源位於物件後方
|
||||
|
||||
# Revolve cap description
|
||||
Close the circular ends of open profiles. Partial sweeps keep their cut sides open. 封閉開放輪廓的圓形端部。部分旋轉的切割側面保持開放。
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1,6 +1,6 @@
|
||||
# Effects parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (wide shadows and glows at high zoom, #1706; stacked Transform effects, #1705; 3D on nonzero compound paths, #1724; pattern tiles redraw, #1723; scores unchanged; Roughen's Smooth turn-backs, #1643; Scribble per region, #1648; score unchanged; Warp Arc bends in the object's proportions, #1578; Zig Zag ridges evenly spaced, #1558; effect parameters checked, #1592; score unchanged; raster effects after a pattern, radial or freeform gradient fill export at their place and size, #1536; score unchanged; 3D and Materials on outlined type and other shapes of many small curves, #1481; Twist 0° to 360°, #1524; twisted walls keep their faces, #1525; score unchanged; 3D and Materials: Inflate takes Twist, Taper and Cap; score unchanged; 3D and Materials: Perspective as the documented 0°–160° angle; 3D and Materials: the documented light settings) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Revolve: automatic smooth shading and default-on Cap with continuous rims; wide shadows and glows at high zoom, #1706; stacked Transform effects, #1705; 3D on nonzero compound paths, #1724; pattern tiles redraw, #1723; scores unchanged; Roughen's Smooth turn-backs, #1643; Scribble per region, #1648; score unchanged; Warp Arc bends in the object's proportions, #1578; Zig Zag ridges evenly spaced, #1558; effect parameters checked, #1592; score unchanged; raster effects after a pattern, radial or freeform gradient fill export at their place and size, #1536; score unchanged; 3D and Materials on outlined type and other shapes of many small curves, #1481; Twist 0° to 360°, #1524; twisted walls keep their faces, #1525; score unchanged; 3D and Materials: Inflate takes Twist, Taper and Cap; score unchanged; 3D and Materials: Perspective as the documented 0°–160° angle; 3D and Materials: the documented light settings) · **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
|
||||
@@ -84,21 +84,22 @@ not an accuracy guarantee for arbitrary artwork. Tests: `pixel::tests::film_grai
|
||||
|
||||
| Item | Ours |
|
||||
|---|---|
|
||||
| Revolve | initial: live, editable profile, rotation and light gizmos, flat shading, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) |
|
||||
| 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)) |
|
||||
| Materials panel, lighting, ray-traced render | one directional light with the documented Intensity, Rotation, Height, Softness and Ambient Light, flat shading; no materials, several lights, light colour or presets, shadows or ray tracing |
|
||||
| Materials panel, lighting, ray-traced render | one directional light with the documented Intensity, Rotation, Height, Softness and Ambient Light, Revolve uses smooth vector shading, the other types use flat facets; no materials, several lights, light colour or presets, shadows or ray tracing |
|
||||
| 3D (Classic): Extrude & Bevel, Revolve, Rotate | none |
|
||||
| Turntable (30.0 beta) | none |
|
||||
| Export 3D objects (OBJ, USDA, glTF) | none |
|
||||
|
||||
The `three-d` crate (depends only on `geom`) builds, rotates, projects, shades and depth-sorts the surfaces;
|
||||
The `three-d` crate (depends on `geom` and `pathops`, with no UI or GPU dependencies) builds, rotates, projects, shades and depth-sorts the surfaces;
|
||||
the effects crate turns them into art. What's left (materials, several lights and shadows, ray-traced rendering,
|
||||
Revolve caps, 3D Classic, Turntable, 3D export) is 25–45 h.
|
||||
angular cut caps, exact visibility, 3D Classic, Turntable, 3D export) is 25–45 h.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | Revolve: automatic smooth vector shading, default-on Cap with continuous curved rims, and bounded seam joins; angular cut sides remain open. Scores and hour estimates unchanged at this rounding |
|
||||
| 2026-10-14 | minor | Wide drop shadows and glows at high zoom render within the raster budget (#1706); stacked Transform effects are bounded (#1705); 3D effects keep the area a nonzero compound path fills (#1724); pattern tiles redraw when a symbol, pattern or image their art shows changes (#1723) (all #1864). Scores unchanged (estimated) |
|
||||
| 2026-10-14 | minor | Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643); Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648); scores unchanged (estimated) |
|
||||
| 2026-10-13 | minor | Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558); Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578); `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592); scores unchanged (estimated) |
|
||||
|
||||
+4
-3
File diff suppressed because one or more lines are too long
+6
-3
@@ -1028,8 +1028,11 @@ Parameters: `angle` (degrees, 0..360, default 360), `offset` (points, 0..100000,
|
||||
`lightRotation` (degrees, -180..180, default 125.26; 0 on the object's right, counterclockwise), `lightHeight`
|
||||
(degrees, 0..90, default 30; 90 straight in front), `lightBehind` (default false), `lightSoftness` (0..100%,
|
||||
default 0), `ambient` (Ambient Light, 0..200%, default 25; an earlier `lightAzimuth` and `lightElevation` are read
|
||||
as the same light), and `expandVisibleOnly` (boolean, default true). A vertical-line profile needs a positive offset to have a radius.
|
||||
Open profiles have no end caps. Visibility of intersecting surfaces is approximate.
|
||||
as the same light), `cap` (boolean, default true), and `expandVisibleOnly` (boolean, default true). A vertical-line profile needs a positive offset to have a radius.
|
||||
`cap` closes each circular endpoint of an open profile with one curved, uniformly shaded
|
||||
vector path. Body faces share that curved rim. Partial sweeps use sectors and leave their angular
|
||||
cut sides open. Closed or coincident-end profiles are unchanged.
|
||||
Missing `cap` enables caps; set it to false for open endpoints. Shaded Revolve surfaces automatically interpolate curved-surface lighting with vector gradients while preserving profile corners and flat caps. There is no separate smoothing parameter. `shade: false` disables all lighting. Visibility of intersecting surfaces is approximate.
|
||||
The dialog's Lighting tab manipulates the same `lightRotation` and `lightHeight` parameters;
|
||||
its guide visibility and tab choice are UI state, excluded from saved effect parameters.
|
||||
`expandVisibleOnly` is saved with the effect. It trims covered portions of opaque solid surfaces only
|
||||
@@ -1040,7 +1043,7 @@ See [Revolve](revolve.md) for the scope and limitations.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"threeD.revolve","item":null,"params":{"angle":360,"offset":20,"edge":"left","rotationX":0,"rotationY":0,"rotationZ":0}}}}
|
||||
{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"angle":180,"lightRotation":30}}}}
|
||||
{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"angle":180,"lightRotation":30,"cap":true}}}}
|
||||
{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"expandVisibleOnly":true}}}}
|
||||
{"name":"run_command","arguments":{"command":"effect.expandAppearance","params":{}}}
|
||||
```
|
||||
|
||||
+38
-8
@@ -14,6 +14,16 @@ Lighting is one directional light with the documented Light Settings (see [three
|
||||
**Intensity**, **Rotation**, **Height**, **Softness** and **Ambient Light**, and **Light behind the object**.
|
||||
Turn **Shading** off to keep the source paint without lighting.
|
||||
|
||||
Shaded Revolve surfaces automatically use smooth lighting from the profile's tangents,
|
||||
represented by linear gradients on vector triangles. There is no separate smoothing control.
|
||||
Segments still controls the body silhouette; smoothing does not increase radial resolution.
|
||||
Capped endpoint rings use curved vector boundaries. Original profile corners remain sharp,
|
||||
and circular end caps keep uniform flat lighting. Turning Shading off disables lighting.
|
||||
Smooth lighting follows the same light gizmo, rotation, perspective, preview, Cancel and undo behavior.
|
||||
SVG and PDF export retain vector paths and gradients; Expand Appearance keeps
|
||||
those gradients at their original positions when trimming visible surfaces. Non-solid
|
||||
source paints retain their paint with a gradient shading overlay.
|
||||
|
||||
The dialog has **Rotation** and **Lighting** tabs. Rotation groups the profile and view controls. Drag a value or its label
|
||||
to adjust it, use its slider, or double-click the value to type. Angles show degrees and lighting
|
||||
values show percentages. **Segments** is in the **Advanced** section. Lighting controls are disabled
|
||||
@@ -47,6 +57,19 @@ The source anchors remain editable. Appearance lists the effect for reopening it
|
||||
SVG/PDF export evaluates filled vector faces; native saves retain the profile and effect parameters.
|
||||
**Object > Expand Appearance** replaces the live effect with ordinary editable vector faces.
|
||||
|
||||
**Cap** in the shape controls closes the circular rings at the endpoints of an open profile.
|
||||
It is on by default. Its saved parameter is `cap` (boolean, default true); missing parameters
|
||||
also use caps. Set it to false to keep the ends open. The reset dot and Reset all restore Cap to on. Each cap is one closed curved vector path from the endpoint ring to the rotation axis,
|
||||
with uniform flat shading. A full turn produces a projected circular disk (an ellipse in an
|
||||
orthographic view); a partial turn produces a sector. Body faces share the same curved rim,
|
||||
so no triangle patches interrupt the cap edge. Caps share the surface's rotation, perspective and paint, and appear in preview,
|
||||
SVG/PDF export and Expand Appearance. The checkbox has a reset dot and is included in Reset all.
|
||||
|
||||
An endpoint on the axis needs no cap. Closed profiles and open profiles whose endpoints coincide
|
||||
keep their existing geometry. Each open subpath of a compound profile is capped independently.
|
||||
A partial revolve adds only the swept sector at each endpoint; the angular cut sides remain open.
|
||||
This option does not make intersecting or compound profiles into a watertight solid.
|
||||
|
||||
In **Advanced**, **Keep visible surfaces only when expanding** is on by default. Expansion removes
|
||||
completely covered faces and trims partially covered ones using the current rotation and perspective.
|
||||
Visible interior surfaces through open ends remain. Uncheck it to retain every projected face.
|
||||
@@ -56,27 +79,34 @@ The checkbox has its own reset dot and is included in **Reset all**. Its saved c
|
||||
Trimming applies to opaque solid paint. Transparent objects, gradients, patterns, non-normal blending,
|
||||
later geometry effects and surfaces exceeding bounded visibility limits keep their complete geometry
|
||||
to preserve their appearance. Visibility follows the same painter order as the live effect, including
|
||||
its approximation for intersecting surfaces. Expansion changes are undoable with the standard command.
|
||||
its approximation for intersecting surfaces. Uncut faces keep their curved outlines. Visibility calculations use bounded polygon approximations
|
||||
of curved rims; clipped portions become vector polygons. Expansion changes are undoable
|
||||
with the standard command.
|
||||
|
||||
## Initial limits
|
||||
|
||||
- Paths and compound paths only. Type, images and groups must be converted to paths first.
|
||||
- Open profiles produce uncapped surfaces. Closed profile boundaries are swept as provided.
|
||||
- Open profiles have circular end caps by default; turning Cap off keeps them open. Partial-sweep angular cut sides remain open. Closed profile boundaries are swept as provided.
|
||||
- One Revolve per source object. Edit its options rather than applying another.
|
||||
- Object-level Revolve uses the top visible fill, falling back to a stroke. Per-item Revolve uses that item's paint.
|
||||
- Flat shading, one directional light, no textures, cast shadows, end caps or physical materials.
|
||||
- Faces use back-to-front depth sorting. Intersecting profiles can have incorrect visibility.
|
||||
- Curves use sixteen samples per segment; profiles are limited to 1,024 samples and surfaces to 32,768 faces.
|
||||
- Smooth diffuse shading, one directional light, no textures, cast shadows or physical materials. Smooth shading interpolates lighting in projected triangles; it does not smooth the silhouette or add specular highlights.
|
||||
- Faces use back-to-front depth sorting. Caps facing the camera cover their rim seams; caps facing away sit behind the body. Intersecting profiles can have incorrect visibility.
|
||||
- Curves use sixteen samples per segment; profiles are limited to 1,024 samples and surfaces to 32,768 faces. Smooth shaded side quads become two triangles and share that same limit. A continuous cap retains its original radial mesh cost within this limit. Projected ring curves have a 0.001-point fitting tolerance and a shared 32,768-curve limit.
|
||||
- Axis, bounds and ordinary selection handles continue to refer to the editable 2D source profile.
|
||||
- Extrude & Bevel, Inflate, Rotate and Materials remain unimplemented.
|
||||
- Extrude & Bevel, Inflate and Rotate are described in [three-d.md](three-d.md). Materials remain unimplemented.
|
||||
|
||||
## Architecture
|
||||
|
||||
`vectorcraft-three-d` depends only on `vectorcraft-geom`. It creates a surface of revolution,
|
||||
Revolve geometry in `vectorcraft-three-d` uses `vectorcraft-geom`; the crate also uses `vectorcraft-pathops`
|
||||
for the other 3D types. It creates a surface of revolution,
|
||||
applies Euler view rotation, projects it, shades faces and sorts their depth. It has no UI or GPU dependency.
|
||||
`vectorcraft-effects` turns those faces into document art. The renderer, export baking and
|
||||
Expand Appearance use the same evaluator. The standard `effect.apply`, `effect.setParams`,
|
||||
`effect.remove` and `effect.expandAppearance` commands expose it to the CLI, control channel and MCP.
|
||||
|
||||
Behaviour reference: [public 3D effects documentation](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html).
|
||||
Generated faces include narrow matching strokes to cover antialiasing seams. Their round
|
||||
joins bound the overlap to half the stroke width, preventing acute triangle corners from
|
||||
creating spikes along the silhouette in the canvas, SVG/PDF and expanded artwork.
|
||||
|
||||
Behaviour references: [public 3D and Materials options](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/3d-materials-panel-options.html) and [public 3D effects documentation](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html).
|
||||
This implementation uses original mathematics and source code, with no Adobe software, assets or presets.
|
||||
|
||||
+3
-2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user