Revolve: the Cap option is saved as cap, as Extrude & Bevel and Inflate save it

This commit is contained in:
bflatastic
2026-10-16 01:36:23 +07:00
parent f703e6bd85
commit b5b9264d71
10 changed files with 40 additions and 40 deletions
+2 -2
View File
@@ -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), capEnds: 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,"capEnds":true,"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,
+8 -8
View File
@@ -39,7 +39,7 @@ pub fn revolve_options(p: &Value) -> Revolve {
ambient: num(p, "ambient", 25.0) / 100.0,
shade: flag(p, "shade", true),
smooth_shading: true,
cap_ends: flag(p, "capEnds", true),
cap_ends: flag(p, "cap", true),
}
}
@@ -54,8 +54,8 @@ 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("capEnds").is_some_and(|v| v.as_bool().is_none()) {
return Err("Revolve capEnds must be a boolean".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(|_| ())
}
@@ -257,7 +257,7 @@ mod tests {
crate::new_effect(
REVOLVE,
&json!({
"offset":20,"rotationX":-18,"segments":24,"capEnds":true,
"offset":20,"rotationX":-18,"segments":24,"cap":true,
"shade":shade,"angle":angle
}),
)
@@ -315,15 +315,15 @@ mod tests {
let mut effect = fx();
effect.params["offset"] = json!(20);
effect.params["rotationX"] = json!(-35);
effect.params.as_object_mut().unwrap().remove("capEnds");
effect.params.as_object_mut().unwrap().remove("cap");
n.appearance.effects.push(effect);
let capped = revolve_art(&n).unwrap();
n.appearance.effects[0].params["capEnds"] = json!(false);
n.appearance.effects[0].params["cap"] = json!(false);
let uncapped = revolve_art(&n).unwrap();
assert_ne!(uncapped, capped);
n.appearance.effects[0].params["capEnds"] = json!(true);
n.appearance.effects[0].params["cap"] = json!(true);
assert_eq!(revolve_art(&n), Some(capped));
n.appearance.effects[0].params["capEnds"] = json!(false);
n.appearance.effects[0].params["cap"] = json!(false);
assert_eq!(revolve_art(&n), Some(uncapped));
}
#[test]
+6 -6
View File
@@ -661,7 +661,7 @@ mod tests {
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["capEnds"].as_bool().unwrap());
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| {
@@ -693,22 +693,22 @@ mod tests {
s.execute(
"effect.apply",
&json!({"effect":"threeD.revolve","ids":[id.0],"item":null,
"params":{"offset":30,"rotationX":-35,"segments":16,"expandVisibleOnly":false,"capEnds":false}}),
"params":{"offset":30,"rotationX":-35,"segments":16,"expandVisibleOnly":false,"cap":false}}),
)
.unwrap();
let uncapped = node(&s, id);
assert_eq!(uncapped.appearance.effects[0].params["capEnds"], json!(false));
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":{"capEnds":true}})).unwrap();
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":{"capEnds":"yes"}})).is_err());
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["capEnds"], json!(true));
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"));
+1 -1
View File
@@ -293,7 +293,7 @@ fn smooth_revolve_exports_axial_vector_shading_without_images_and_keeps_the_sour
n.appearance.effects.push(vectorcraft_doc::Effect {
id: "threeD.revolve".into(),
visible: true,
params: serde_json::json!({"offset":20,"rotationX":-25,"capEnds":true,"segments":16}),
params: serde_json::json!({"offset":20,"rotationX":-25,"cap":true,"segments":16}),
});
add(&mut d, n);
let before = d.clone();
+4 -4
View File
@@ -49,10 +49,10 @@ fn revolve_caps_cover_the_opening_and_match_export_and_visible_only_expansion()
n.appearance.effects = vec![fx(
"threeD.revolve",
json!({"offset":20,"rotationX":-35,"lightAzimuth":0,
"angle":angle,"segments":32,"capEnds":false,"expandVisibleOnly":true}),
"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["capEnds"] = json!(true);
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}");
@@ -158,7 +158,7 @@ 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,"capEnds":true})));
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();
@@ -197,7 +197,7 @@ fn continuous_revolve_caps_keep_one_colour_along_the_curved_rim_at_high_zoom() {
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,"capEnds":true,"shade":shade})));
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;
+3 -3
View File
@@ -25,11 +25,11 @@ fn fx(id: &str) -> Effect {
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,"capEnds":false}), ..fx("threeD.revolve") });
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["capEnds"] = json!(true);
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");
@@ -62,7 +62,7 @@ fn smooth_revolve_exports_vector_gradients_without_rasterizing_or_editing_the_so
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,"capEnds":true,"segments":16}), ..fx("threeD.revolve") });
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());
+4 -4
View File
@@ -91,15 +91,15 @@ 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("capEnds");
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("capEnds".into(), json!(cap));
d.fields.insert("cap".into(), json!(cap));
}
ui.add_space((ui.available_width() - 24.0).max(0.0));
if reset_dot(ui, "capEnds", !cap, "true") {
d.fields.insert("capEnds".into(), json!(true));
if reset_dot(ui, "cap", !cap, "true") {
d.fields.insert("cap".into(), json!(true));
}
});
ui.add_space(10.0);
+7 -7
View File
@@ -320,24 +320,24 @@ fn cap_checkbox_previews_resets_and_respects_cancel_and_one_step_undo() {
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("capEnds"), "Cap starts on");
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("capEnds"));
assert!(!h.app.ui.dialog.as_ref().unwrap().bool("cap"));
assert_ne!(art(&h), capped);
h.click(h.response(("revolve-reset", "capEnds")).center());
assert!(h.app.ui.dialog.as_ref().unwrap().bool("capEnds"));
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("capEnds"));
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["capEnds"], json!(false));
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["capEnds"], json!(false));
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);
+4 -4
View File
@@ -1028,11 +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), `capEnds` (boolean, default true), and `expandVisibleOnly` (boolean, default true). A vertical-line profile needs a positive offset to have a radius.
`capEnds` closes each circular endpoint of an open profile with one curved, uniformly shaded
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 `capEnds` 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.
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
@@ -1043,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,"capEnds":true}}}}
{"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":{}}}
```
+1 -1
View File
@@ -58,7 +58,7 @@ SVG/PDF export evaluates filled vector faces; native saves retain the profile an
**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 `capEnds` (boolean, default true); missing parameters
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,