Add lighting presets to 3D effects

Squashed from #3442 onto main.
This commit is contained in:
bflatastic
2026-10-22 00:01:37 +07:00
parent 4d1581f3da
commit 2cd7f156fb
39 changed files with 475 additions and 16 deletions
+37
View File
@@ -55,6 +55,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
set_params
),
cmd!(
"effect.lightingPreset",
"3D Lighting Preset",
[],
None,
"{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index; one undo step → {ids}",
has_doc,
lighting_preset
),
cmd!(
"effect.expandAppearance",
"Expand Appearance",
@@ -300,6 +309,30 @@ pub(crate) fn set_params_effects(s: &Session, p: &Value) -> Vec<String> {
ids.iter().filter_map(effect).collect()
}
fn lighting_preset(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.lightingPreset";
let patch = str_param(p, "preset")
.and_then(effects::lighting_preset_params)
.ok_or_else(|| bad(C, "preset must be standard, diffuse, topLeft or right"))?;
let index = index_param(p, "index", C)?;
let item = item_target(s, p, C)?;
// Validate the whole selection before changing any object. An unrelated effect or
// a missing index must not leave just some objects with a new lighting rig.
for id in appearance_targets(s, p)? {
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
let slot = item.effects_item(&n.appearance, C)?;
let effect = n.appearance.effects_at(slot).and_then(|fx| fx.get(index));
if !effect.is_some_and(|e| effects::is_three_d(&e.id)) {
return Err(bad(C, format!("object {} needs a 3D effect at index {index}", id.0)));
}
}
let mut options = p.clone();
let map = options.as_object_mut().ok_or_else(|| bad(C, "parameters must be an object"))?;
map.remove("preset");
map.insert("params".into(), patch);
set_params(s, &options)
}
fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.setParams";
let index = index_param(p, "index", C)?;
@@ -673,6 +706,10 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
Ok(out)
}
#[cfg(test)]
#[path = "tests_lighting_presets.rs"]
mod lighting_presets_tests;
#[cfg(test)]
mod tests {
use serde_json::json;
@@ -0,0 +1,93 @@
#![allow(clippy::unwrap_used, clippy::panic)]
use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
use vectorcraft_render::effects::{LIGHTING_PRESETS, matching_lighting_preset};
use crate::Session;
fn setup(effect: &str, item: Value) -> (Session, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width":400,"height":400})).unwrap();
let id = NodeId(s.execute("path.create", &json!({"d":"M100 100L200 100L200 200L100 200Z"})).unwrap()["id"].as_u64().unwrap());
s.execute("paint.setFill", &json!({"color":"#338ad6"})).unwrap();
s.execute(
"effect.apply",
&json!({"effect":effect,"item":item,"params":{
"rotationX":13,"rotationY":17,"rotationZ":9,"shade":false,"ambient":17,
"lights":[{"color":"#ff8040"},{"color":"#4080ff","rotation":-35}]
}}),
)
.unwrap();
(s, id)
}
#[test]
fn lighting_presets_preserve_source_and_view_and_round_trip_in_one_undo_step() {
for effect in ["threeD.revolve", "threeD.extrude", "threeD.inflate", "threeD.rotate"] {
for (i, preset) in LIGHTING_PRESETS.iter().enumerate() {
let (mut s, id) = setup(effect, Value::Null);
s.doc_mut().unwrap().mark_saved();
let before = s.doc().unwrap().doc.clone();
let history = s.doc().unwrap().history.undo.len();
let p = json!({"index":0,"item":null,"preset":preset.id});
s.check_params("effect.lightingPreset", &p).unwrap();
s.execute("effect.lightingPreset", &p).unwrap();
let after = s.doc().unwrap().doc.clone();
let actual = &after.node(id).unwrap().appearance.effects[0].params;
let mut expected = before.node(id).unwrap().appearance.effects[0].params.clone();
expected.as_object_mut().unwrap().extend(preset.params().as_object().unwrap().clone());
assert_eq!(*actual, expected);
assert_eq!(matching_lighting_preset(actual), Some(i));
assert_eq!(after.node(id).unwrap().path_data(), before.node(id).unwrap().path_data());
assert_eq!(actual["rotationX"], 13);
assert_eq!(actual["shade"], false);
assert_eq!(s.doc().unwrap().history.undo.len(), history + 1);
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&after, false)).unwrap();
assert_eq!(reopened.node(id).unwrap().appearance.effects[0].params, *actual);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, before);
assert!(!s.doc().unwrap().is_dirty());
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc, after);
}
}
}
#[test]
fn lighting_presets_target_fill_effects_and_reject_invalid_requests_atomically() {
let (mut s, id) = setup("threeD.rotate", json!(0));
for p in [
json!({"index":0,"item":0,"preset":"unknown"}),
json!({"index":0,"item":0,"preset":12}),
json!({"index":1,"item":0,"preset":"right"}),
json!({"index":0,"item":null,"preset":"right"}),
json!({"index":0,"item":0,"preset":"right","params":{"ambient":200}}),
] {
s.doc_mut().unwrap().mark_saved();
let before = s.doc().unwrap().doc.clone();
let history = s.doc().unwrap().history.undo.len();
assert!(s.execute("effect.lightingPreset", &p).is_err(), "{p}");
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().history.undo.len(), history);
assert!(!s.doc().unwrap().is_dirty());
}
s.execute("effect.lightingPreset", &json!({"ids":[id.0],"index":0,"item":0,"preset":"topLeft"})).unwrap();
let n = s.doc().unwrap().doc.node(id).unwrap();
assert!(n.appearance.effects.is_empty());
assert_eq!(n.appearance.effects_at(Some(0)).unwrap()[0].params["lightRotation"], 135.0);
}
#[test]
fn lighting_presets_reject_a_mixed_selection_before_changing_any_effect() {
let (mut s, first) = setup("threeD.rotate", Value::Null);
let second = s.execute("shape.rectangle", &json!({"x":250,"y":100,"width":80,"height":80})).unwrap()["id"].clone();
s.execute("effect.apply", &json!({"effect":"path.offsetPath","item":null})).unwrap();
s.execute("select.set", &json!({"ids":[first.0,second]})).unwrap();
s.doc_mut().unwrap().mark_saved();
let before = s.doc().unwrap().doc.clone();
let history = s.doc().unwrap().history.undo.len();
assert!(s.execute("effect.lightingPreset", &json!({"index":0,"item":null,"preset":"right"})).is_err());
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().history.undo.len(), history);
assert!(!s.doc().unwrap().is_dirty());
}