From ba25415792f581719eabf51ded1be32818be46a3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 7 Nov 2026 10:10:58 -0800 Subject: [PATCH] =?UTF-8?q?3D=20and=20Materials:=20Window=20=E2=80=BA=203D?= =?UTF-8?q?=20and=20Materials=20edits=20the=20selection's=203D=20effect=20?= =?UTF-8?q?in=20place?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel opens from Window › 3D and Materials (window.panel {panel: "threeD"}). Its object types Plane, Extrude, Revolve and Inflate apply Rotate, Extrude & Bevel, Revolve or Inflate to each selected object without a 3D effect, and change the 3D effect of each other selected object in place, all as one undo step. Below them are the effect dialog's Object, Rotation and Lighting tabs with the same controls. A changed setting goes to every selected 3D effect that takes it, as one undo step; a drag previews and records one step on release, and a change of selection during a drag commits it first, and so does closing or hiding the panel. A setting the selected effects hold with different values shows its default with its reset dot filled. A light list edit reaches only the objects whose list matches the first object's. effect.setParams takes threeDType (plane, extrude, revolve, inflate): the settings both effects take keep their values, rotation angles wrap into -180..180 outside Revolve, and the rest start at the new effect's defaults. Another value, or a non-3D effect at index, is refused; the type an effect already has, with nothing else to change, records no undo step. Materials, shadows and ray-traced rendering are not part of this. Docs: docs/three-d.md, docs/mcp.md, docs/effects-parity.md, docs/target-app-parity.md. Fixes #3672 --- crates/effects/src/lib.rs | 4 +- crates/effects/src/solids.rs | 26 ++ crates/engine/src/cmd/effectcmd.rs | 65 +++- .../src/cmd/effectcmd/three_d_type_tests.rs | 144 ++++++++ crates/ui-egui/src/app.rs | 2 + crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/dialogs/revolve.rs | 16 +- crates/ui-egui/src/i18n/bg.tsv | 5 + crates/ui-egui/src/i18n/bs.tsv | 5 + crates/ui-egui/src/i18n/ca.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/da.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fi.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hi.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/id.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ka.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/nl.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sv.tsv | 5 + crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + crates/ui-egui/src/menus/tree.rs | 1 + crates/ui-egui/src/panels.rs | 2 + crates/ui-egui/src/panels/tests_three_d.rs | 347 ++++++++++++++++++ crates/ui-egui/src/panels/three_d.rs | 272 ++++++++++++++ crates/ui-egui/src/state.rs | 1 + docs/effects-parity.md | 2 +- docs/mcp.md | 15 + docs/target-app-parity.md | 1 + docs/three-d.md | 29 +- 43 files changed, 1047 insertions(+), 17 deletions(-) create mode 100644 crates/engine/src/cmd/effectcmd/three_d_type_tests.rs create mode 100644 crates/ui-egui/src/panels/tests_three_d.rs create mode 100644 crates/ui-egui/src/panels/three_d.rs diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 8835d769f..b251b15a6 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -84,8 +84,8 @@ pub use reshape::{ }; pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use solids::{ - EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid, - validate_three_d, view_options, + EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, THREE_D_TYPES, extrude_options, has_solid, inflate_options, is_three_d, solid_art, + three_d_params_as, validate_solid, validate_three_d, view_options, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use trap::TRAP; diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 7025f8f6f..3319d79af 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -25,6 +25,32 @@ pub fn is_three_d(id: &str) -> bool { id == crate::REVOLVE || SOLIDS.contains(&id) } +/// The 3D and Materials panel's object types and the effect that draws each: (type, effect id). +/// Plane keeps the art flat, which is what Rotate draws. +pub const THREE_D_TYPES: [(&str, &str); 4] = [("plane", ROTATE), ("extrude", EXTRUDE), ("revolve", crate::REVOLVE), ("inflate", INFLATE)]; + +/// The parameters of 3D effect `to` for an effect `from` with `params`, as switching the object +/// type keeps them: every setting `to` takes keeps its value from `params` (the defaults of `from` +/// under them), each rotation angle wrapped into -180° to 180° when `to` is not Revolve, and the +/// settings only `to` takes start at their defaults. Settings `to` does not take are dropped. +pub fn three_d_params_as(from: &str, params: &Value, to: &str) -> Value { + let current = crate::merged_params(from, params); + let mut out = crate::default_params(to).and_then(|v| v.as_object().cloned()).unwrap_or_default(); + for (key, value) in out.iter_mut() { + if let Some(v) = current.get(key) { + value.clone_from(v); + } + } + if to != crate::REVOLVE { + for key in ["rotationX", "rotationY", "rotationZ"] { + if let Some(a) = out.get(key).and_then(Value::as_f64).filter(|a| !(-180.0..=180.0).contains(a)) { + out.insert(key.into(), serde_json::json!((a + 180.0).rem_euclid(360.0) - 180.0)); + } + } + } + Value::Object(out) +} + fn solid(fx: &[Effect]) -> Option<(usize, &Effect)> { fx.iter().enumerate().find(|(_, e)| e.visible && SOLIDS.contains(&e.id.as_str())) } diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index b67d5bb97..e6568c5ea 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -53,7 +53,7 @@ pub fn specs() -> Vec { "Effect Options", [], None, - "{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}", + "{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}", has_doc, set_params ), @@ -422,10 +422,28 @@ pub(crate) fn set_params_effects(s: &Session, p: &Value) -> Vec { return vec![]; }; let Ok(targets) = effect_targets(s, p, item, index, C) else { return vec![] }; - let effect = |&(id, at, index): &(NodeId, Option, usize)| Some(st.doc.node(id)?.appearance.effects_at(at)?.get(index)?.id.clone()); + // A 3D effect whose type `threeDType` changes takes the new effect's parameters. + let kind = three_d_type(p, C).ok().flatten(); + let effect = |&(id, at, index): &(NodeId, Option, usize)| { + let id = &st.doc.node(id)?.appearance.effects_at(at)?.get(index)?.id; + Some(kind.filter(|_| effects::is_three_d(id)).map_or_else(|| id.clone(), str::to_string)) + }; targets.iter().filter_map(effect).collect() } +/// The effect `threeDType` in `p` turns a 3D effect into ([`effects::THREE_D_TYPES`]), when given. +fn three_d_type(p: &Value, cmd: &str) -> Result> { + let key = "threeDType"; + let Some(v) = p.get(key) else { return Ok(None) }; + match v.as_str().and_then(|t| effects::THREE_D_TYPES.iter().find(|(name, _)| *name == t)) { + Some((_, id)) => Ok(Some(id)), + None => { + let names: Vec<&str> = effects::THREE_D_TYPES.iter().map(|(name, _)| *name).collect(); + Err(bad(cmd, format!("`{key}` must be one of {}", names.join(", ")))) + } + } +} + fn lighting_preset(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.lightingPreset"; let patch = str_param(p, "preset") @@ -450,8 +468,9 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result { } /// `effect.setParams`: merges `params` into the effect at `index` of the targets' active item, and -/// sets its Warp style (`warpStyle`) or visibility (`visible`), as one undo step. They are checked -/// as `effect.apply` checks them, and a visible 3D effect must still fit. → `{ids}`. +/// sets its Warp style (`warpStyle`), its 3D type (`threeDType`) or visibility (`visible`), as one +/// undo step. They are checked as `effect.apply` checks them, and a visible 3D effect must still +/// fit. → `{ids}`. fn set_params(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.setParams"; let index = index_param(p, "index", C)?; @@ -459,6 +478,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result { if !params.is_null() && !params.is_object() { return Err(bad(C, "`params` must be an object")); } + let kind = three_d_type(p, C)?; let warp_id = p .get("warpStyle") .map(|v| -> Result { @@ -476,21 +496,41 @@ fn set_params(s: &mut Session, p: &Value) -> Result { if edited.iter().any(|e| texture_load_effect(e)) { load_texture(&mut params, C)?; } - for (id, target, index) in effect_targets(s, p, item, index, C)? { + let targets = effect_targets(s, p, item, index, C)?; + // The type each 3D effect already has, with nothing else to change, is no edit (and is not + // checked again). + let nothing_else = params.as_object().is_none_or(serde_json::Map::is_empty) && visible.is_none() && warp_id.is_none(); + if let Some(to) = kind + && nothing_else + { + let d = &s.doc()?.doc; + // The effects edited: a target without an effect at `index` is left as it is. + let ids: Vec<&str> = + targets.iter().filter_map(|&(id, at, index)| d.node(id)?.appearance.effects_at(at)?.get(index).map(|e| e.id.as_str())).collect(); + if !ids.is_empty() && ids.iter().all(|id| *id == to) { + return Ok(json!({ "ids": [] })); + } + } + for &(id, target, index) in &targets { let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?; let effect = n.appearance.effects_at(target).and_then(|fx| fx.get(index)); if warp_id.is_some() && effect.is_some_and(|e| e.id.strip_prefix("warp.").and_then(effects::WarpStyle::from_id).is_none()) { return Err(bad(C, "warpStyle can only edit an applied Warp effect")); } + if kind.is_some() && 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))); + } if let Some(e) = effect && effects::is_three_d(&e.id) && visible != Some(false) { - let mut prospective = effects::merged_params(&e.id, &e.params); - if let (Some(cur), Value::Object(new)) = (prospective.as_object_mut(), effects::upgrade_params(&e.id, ¶ms)) { + let to = kind.unwrap_or(&e.id); + let mut prospective = + if to == e.id { effects::merged_params(&e.id, &e.params) } else { effects::three_d_params_as(&e.id, &e.params, to) }; + if let (Some(cur), Value::Object(new)) = (prospective.as_object_mut(), effects::upgrade_params(to, ¶ms)) { cur.extend(new); } - effects::validate_three_d(n, &e.id, &prospective).map_err(|e| bad(C, e))?; + effects::validate_three_d(n, to, &prospective).map_err(|e| bad(C, e))?; } } let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), index, |fx, index| { @@ -498,6 +538,12 @@ fn set_params(s: &mut Session, p: &Value) -> Result { if let Some(id) = &warp_id { e.id.clone_from(id); } + if let Some(to) = kind + && e.id != to + { + e.params = effects::three_d_params_as(&e.id, &e.params, to); + e.id = to.to_string(); + } if let (Value::Object(new), cur) = (effects::upgrade_params(&e.id, ¶ms), &mut e.params) { if !cur.is_object() { *cur = effects::merged_params(&e.id, &Value::Null); @@ -859,5 +905,8 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod lighting_presets_tests; +#[cfg(test)] +mod three_d_type_tests; + #[cfg(test)] mod tests; diff --git a/crates/engine/src/cmd/effectcmd/three_d_type_tests.rs b/crates/engine/src/cmd/effectcmd/three_d_type_tests.rs new file mode 100644 index 000000000..5fef22909 --- /dev/null +++ b/crates/engine/src/cmd/effectcmd/three_d_type_tests.rs @@ -0,0 +1,144 @@ +#![allow(clippy::unwrap_used, clippy::panic)] +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; + +use crate::Session; + +fn setup(effect: &str, params: 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":null,"params":params})).unwrap(); + (s, id) +} + +fn effect(s: &Session, id: NodeId) -> (String, Value) { + let e = &s.doc().unwrap().doc.node(id).unwrap().appearance.effects[0]; + (e.id.clone(), e.params.clone()) +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +#[test] +fn three_d_type_turns_the_effect_in_place_keeping_shared_settings_in_one_undo_step() { + let types = [("plane", "threeD.rotate"), ("extrude", "threeD.extrude"), ("revolve", "threeD.revolve"), ("inflate", "threeD.inflate")]; + for (from_type, from) in types { + for (to_type, to) in types { + if from == to { + continue; + } + let (mut s, id) = setup(from, json!({"rotationX": 12.0, "rotationY": -30.0, "rotationZ": 5.0, "ambient": 40.0, "lightIntensity": 55.0})); + let before = s.doc().unwrap().doc.clone(); + let history = undo_len(&s); + let p = json!({"index": 0, "item": null, "threeDType": to_type}); + s.check_params("effect.setParams", &p).unwrap(); + s.execute("effect.setParams", &p).unwrap(); + let (now, params) = effect(&s, id); + assert_eq!(now, to, "{from_type} → {to_type}"); + assert_eq!(params["rotationX"], 12.0, "{from_type} → {to_type}"); + assert_eq!(params["rotationY"], -30.0); + assert_eq!(params["ambient"], 40.0); + assert_eq!(params["lightIntensity"], 55.0); + // Settings only the new effect takes start at its defaults; others are dropped. + let defaults = vectorcraft_render::effects::default_params(to).unwrap(); + for key in params.as_object().unwrap().keys() { + assert!(defaults.get(key).is_some(), "{from_type} → {to_type} kept `{key}`"); + } + if to == "threeD.extrude" { + assert_eq!(params["bevel"], defaults["bevel"]); + } + assert_eq!(undo_len(&s), history + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + } + } +} + +#[test] +fn three_d_type_carries_depth_between_extrude_and_inflate_and_merges_params_after() { + let (mut s, id) = setup("threeD.extrude", json!({"depth": 80.0, "twist": 30.0, "bevel": "classic"})); + s.execute("effect.setParams", &json!({"index": 0, "threeDType": "inflate", "params": {"volume": 70.0}})).unwrap(); + let (now, params) = effect(&s, id); + assert_eq!(now, "threeD.inflate"); + assert_eq!(params["depth"], 80.0); + assert_eq!(params["twist"], 30.0); + assert_eq!(params["volume"], 70.0); + assert!(params.get("bevel").is_none()); +} + +#[test] +fn revolve_angles_outside_half_a_turn_wrap_when_the_type_leaves_revolve() { + let (mut s, id) = setup("threeD.revolve", json!({"rotationX": 270.0, "rotationY": -200.0})); + s.execute("effect.setParams", &json!({"index": 0, "threeDType": "plane"})).unwrap(); + let (_, params) = effect(&s, id); + assert_eq!(params["rotationX"], -90.0); + assert_eq!(params["rotationY"], 160.0); +} + +#[test] +fn the_current_type_alone_changes_nothing_and_records_no_undo_step() { + let (mut s, id) = setup("threeD.extrude", json!({"depth": 80.0})); + let before = s.doc().unwrap().doc.clone(); + let history = undo_len(&s); + let r = s.execute("effect.setParams", &json!({"index": 0, "threeDType": "extrude"})).unwrap(); + assert_eq!(r["ids"], json!([])); + assert_eq!(undo_len(&s), history); + assert_eq!(s.doc().unwrap().doc, before); + // With other changes the edit runs as usual. + s.execute("effect.setParams", &json!({"index": 0, "threeDType": "extrude", "params": {"depth": 90.0}})).unwrap(); + assert_eq!(effect(&s, id).1["depth"], 90.0); + assert_eq!(undo_len(&s), history + 1); +} + +#[test] +fn the_current_type_with_an_object_without_effects_selected_too_records_no_undo_step() { + let (mut s, a) = setup("threeD.extrude", json!({})); + let b = NodeId(s.execute("path.create", &json!({"d":"M250 100L350 100L350 200L250 200Z"})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let history = undo_len(&s); + s.execute("effect.setParams", &json!({"index": 0, "item": null, "aligned": true, "threeDType": "extrude"})).unwrap(); + assert_eq!(undo_len(&s), history); + assert_eq!(s.doc().unwrap().doc, before); +} + +#[test] +fn three_d_type_rejects_unknown_types_and_effects_that_are_not_3d() { + let (mut s, _) = setup("threeD.extrude", json!({})); + let before = s.doc().unwrap().doc.clone(); + for t in [json!("cube"), json!(""), json!(null), json!(2), json!("Plane"), json!("plane/../x")] { + let p = json!({"index": 0, "threeDType": t}); + let err = s.execute("effect.setParams", &p).unwrap_err().to_string(); + assert!(err.contains("must be one of plane, extrude, revolve, inflate"), "{t}: {err}"); + } + assert_eq!(s.doc().unwrap().doc, before); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "item": null})).unwrap(); + let err = s.execute("effect.setParams", &json!({"index": 1, "threeDType": "plane"})).unwrap_err().to_string(); + assert!(err.contains("needs a 3D effect at index 1"), "{err}"); + let err = s.execute("effect.setParams", &json!({"index": 5, "threeDType": "plane"})).unwrap_err().to_string(); + assert!(err.contains("no effect at index 5"), "{err}"); +} + +#[test] +fn three_d_type_turns_every_selected_object_in_one_undo_step() { + let (mut s, a) = setup("threeD.rotate", json!({})); + let b = NodeId(s.execute("path.create", &json!({"d":"M250 100L350 100L350 200L250 200Z"})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"color":"#d63333"})).unwrap(); + s.execute("effect.apply", &json!({"effect":"threeD.rotate","item":null})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let history = undo_len(&s); + s.execute("effect.setParams", &json!({"index": 0, "item": null, "aligned": true, "threeDType": "extrude"})).unwrap(); + assert_eq!(effect(&s, a).0, "threeD.extrude"); + assert_eq!(effect(&s, b).0, "threeD.extrude"); + assert_eq!(undo_len(&s), history + 1); +} + +#[test] +fn params_for_the_new_type_pass_the_parameter_check() { + let (s, _) = setup("threeD.rotate", json!({})); + // `depth` is an Extrude & Bevel setting, checked against the effect the type makes. + s.check_params("effect.setParams", &json!({"index": 0, "threeDType": "extrude", "params": {"depth": 20.0}})).unwrap(); +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 107481de9..79d3d8f0d 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -557,6 +557,8 @@ impl VectorcraftApp { trace_runs::show(self, &ctx); opening::show(self, &ctx); palette::show(self, &ctx); + // After every panel had its chance to draw in this pass. + crate::panels::three_d::end_stale_drag(self, &ctx); if self.custom_titlebar { titlebar::resize_zones(ui); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index ed9f909b3..4193f8d6a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -93,7 +93,7 @@ pub mod rearrange_artboards; pub mod recolor; mod recovery; pub mod rename_artboards; -mod revolve; +pub(crate) mod revolve; pub(crate) mod revolve_gizmo; pub mod saturate; pub mod save_brush_library; diff --git a/crates/ui-egui/src/dialogs/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs index 7146117b0..5966cf7d4 100644 --- a/crates/ui-egui/src/dialogs/revolve.rs +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -60,7 +60,10 @@ pub(super) fn body(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { }); } -fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { +/// The tabs and controls of the 3D effect in `d.fields["__effect"]`, editing `d.fields`; the +/// dialog's body and the 3D and Materials panel draw them. With `__panel` set (the panel), the +/// controls that only the dialog's artboard gizmos use are left out. +pub(crate) fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.spacing_mut().item_spacing.y = 4.0; let id = d.str("__effect"); if id != REVOLVE { @@ -115,7 +118,9 @@ fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.add_space(6.0); egui::CollapsingHeader::new(tl!("Advanced")).id_salt("revolve-advanced").show(ui, |ui| { number(ui, d, "segments", tl!("Segments"), (8.0, 128.0), "", 0, None); - super::form::check(ui, d, "__showAxis", tl!("Show axis")); + if !d.bool("__panel") { + super::form::check(ui, d, "__showAxis", tl!("Show axis")); + } ui.add_space(6.0); ui.horizontal(|ui| { let visible = d.fields.get("expandVisibleOnly").and_then(Value::as_bool).unwrap_or(true); @@ -275,7 +280,8 @@ fn toggle(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, default: boo d.fields.insert(key.into(), json!(!on)); } ui.add_space((ui.available_width() - 24.0).max(0.0)); - if reset_dot(ui, key, on != default, if default { "true" } else { "false" }) { + let mixed = d.fields.get(key).is_some_and(Value::is_null); + if reset_dot(ui, key, mixed || on != default, if default { "true" } else { "false" }) { d.fields.insert(key.into(), json!(default)); } }); @@ -550,7 +556,9 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64, if let Some(n) = slider.0.map(|n| rail_min + f64::from(n) * (rail_max - rail_min)).or(slider.2) { next = Some(n); } - if reset_dot(ui, key, (to(value) - default).abs() > 1e-6, &format!("{}{}", from(default), suffix)) { + // A setting the 3D and Materials panel's selected effects hold with different values is null. + let mixed = d.fields.get(key).is_some_and(Value::is_null); + if reset_dot(ui, key, mixed || (to(value) - default).abs() > 1e-6, &format!("{}{}", from(default), suffix)) { next = Some(from(default)); } }); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 5d8917235..df2b9b4d8 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -70,6 +70,11 @@ 3 Color Job… Задача с 3 цвята… 30° Angles Ъгли от 30° 3D and Materials 3D и материали + Plane Равнина + Extrude Избутване + Revolve Завъртане около ос + Inflate Издуване + Select objects, then pick an object type to give them a 3D effect. Изберете обекти, след това изберете тип обект, за да им придадете 3D ефект. 3D Ribbon 3D лента 45° Angles Ъгли от 45° 60° Angles Ъгли от 60° diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 8cdbcfb04..ac0a9cc5f 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -448,6 +448,11 @@ Document Raster Effects Settings… Postavke rasterskih efekata dokumenta… Vector Effects Vektorski efekti 3D and Materials 3D i materijali + Plane Ravan + Extrude Istiskivanje + Revolve Rotaciono tijelo + Inflate Napuhni + Select objects, then pick an object type to give them a 3D effect. Odaberite objekte, zatim odaberite vrstu objekta da im date 3D efekat. Extrude & Bevel… Istiskivanje i zakošenje… Revolve… Rotaciono tijelo… Inflate… Napuhni… diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 60b0c80cd..22ce8e4eb 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -107,6 +107,11 @@ 32 bit 32 bits 32 bits/pixel 32 bits/píxel 3D and Materials 3D i materials + Plane Pla + Extrude Extrusió + Revolve Revolució + Inflate Infla + Select objects, then pick an object type to give them a 3D effect. Seleccioneu objectes i després trieu un tipus d’objecte per donar-los un efecte 3D. 3D Lighting Preset Preajust d’il·luminació 3D 3D Ribbon Cinta 3D 4 bit 4 bits diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 77add87f3..12421cd4b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -456,6 +456,11 @@ Document Raster Effects Settings… Nastavení rastrových efektů dokumentu… Vector Effects Vektorové efekty 3D and Materials 3D a materiály + Plane Rovina + Extrude Vysunutí + Revolve Rotační těleso + Inflate Nafouknutí + Select objects, then pick an object type to give them a 3D effect. Vyberte objekty a pak zvolte typ objektu, abyste jim dali 3D efekt. Extrude & Bevel… Vysunutí a zkosení… Revolve… Rotační těleso… Inflate… Nafouknutí… diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 826beac28..8ab0e613c 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -414,6 +414,11 @@ Document Raster Effects Settings… Indstillinger for rastereffekter i dokumentet… Vector Effects Vektoreffekter 3D and Materials 3D og materialer + Plane Plan + Extrude Ekstrudér + Revolve Omdrej + Inflate Pust op + Select objects, then pick an object type to give them a 3D effect. Markér objekter, og vælg derefter en objekttype for at give dem en 3D-effekt. Extrude & Bevel… Ekstrudér og facet… Revolve… Omdrej… Inflate… Pust op… diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b0cca17b2..7f2fc4ca8 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -59,6 +59,11 @@ 3 Color Job… 3-Farben-Auftrag… 30° Angles 30°-Winkel 3D and Materials 3D und Materialien + Plane Ebene + Extrude Extrudieren + Revolve Kreiseln + Inflate Aufblasen + Select objects, then pick an object type to give them a 3D effect. Wählen Sie Objekte aus und dann einen Objekttyp, um ihnen einen 3D-Effekt zu geben. 3D Ribbon 3D-Band 45° Angles 45°-Winkel 60° Angles 60°-Winkel diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 2c26e7c69..8ad6f40d3 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -66,6 +66,11 @@ 3 Color Job… Εργασία τριών χρωμάτων… 30° Angles Γωνίες 30° 3D and Materials 3D και υλικά + Plane Επίπεδο + Extrude Εξώθηση + Revolve Περιστροφή γύρω από άξονα + Inflate Φούσκωμα + Select objects, then pick an object type to give them a 3D effect. Επιλέξτε αντικείμενα και, στη συνέχεια, έναν τύπο αντικειμένου για να τους δώσετε εφέ 3D. 3D Ribbon Τρισδιάστατη κορδέλα 45° Angles Γωνίες 45° 60° Angles Γωνίες 60° diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index cf85ae59f..9114fa1d8 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -84,6 +84,11 @@ 3 Color Job… Trabajo a 3 colores… 30° Angles Ángulos de 30° 3D and Materials 3D y materiales + Plane Plano + Extrude Extrusión + Revolve Revolución + Inflate Inflar + Select objects, then pick an object type to give them a 3D effect. Seleccione objetos y luego elija un tipo de objeto para darles un efecto 3D. 3D Ribbon Cinta 3D 45° Angles Ángulos de 45° 60° Angles Ángulos de 60° diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 9bf9d0c67..433a38e89 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -453,6 +453,11 @@ Document Raster Effects Settings… Asiakirjan rasteritehosteiden asetukset… Vector Effects Vektoritehosteet 3D and Materials 3D ja materiaalit + Plane Taso + Extrude Pursota + Revolve Pyöräytä + Inflate Täytä ilmalla + Select objects, then pick an object type to give them a 3D effect. Valitse objektit ja valitse sitten objektityyppi, niin niille tulee 3D-tehoste. Extrude & Bevel… Pursota ja viistä… Revolve… Pyöräytä… Inflate… Täytä ilmalla… diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 72e643ba9..dd24d124e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -85,6 +85,11 @@ 3 Color Job… Travail en 3 couleurs… 30° Angles Angles de 30° 3D and Materials 3D et matières + Plane Plan + Extrude Extrusion + Revolve Révolution + Inflate Gonfler + Select objects, then pick an object type to give them a 3D effect. Sélectionnez des objets, puis choisissez un type d’objet pour leur donner un effet 3D. 3D Ribbon Ruban 3D 45° Angles Angles de 45° 60° Angles Angles de 60° diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 318b841c9..b2a596b6f 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -148,6 +148,11 @@ 32 bit 32 बिट 32 bits/pixel 32 बिट्स/पिक्सेल 3D and Materials 3D और मटीरियल्स + Plane समतल + Extrude एक्सट्रूड + Revolve रिवॉल्व + Inflate फुलाएँ + Select objects, then pick an object type to give them a 3D effect. ऑब्जेक्ट्स सिलेक्ट करें, फिर उन्हें 3D इफ़ेक्ट देने के लिए एक ऑब्जेक्ट टाइप चुनें। 3D Box 3D बॉक्स 3D Box Tool 3D बॉक्स टूल 3D Lighting Preset 3D लाइटिंग प्रीसेट diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 43b7fd772..4c79d9415 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -111,6 +111,11 @@ 32 bit 32 bit 32 bits/pixel 32 bit/pixel 3D and Materials 3D és anyagok + Plane Sík + Extrude Kihúzás + Revolve Forgatás tengely körül + Inflate Felfújás + Select objects, then pick an object type to give them a 3D effect. Jelöljön ki objektumokat, majd válasszon objektumtípust, hogy 3D-effektust kapjanak. 3D Ribbon 3D szalag 4 bit 4 bit 4-Up 4 nézet diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 21adbb444..f4d529a6f 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -414,6 +414,11 @@ Document Raster Effects Settings… Pengaturan efek raster dokumen… Vector Effects Efek vektor 3D and Materials 3D dan material + Plane Bidang + Extrude Ekstrusi + Revolve Revolusi + Inflate Gelembungkan + Select objects, then pick an object type to give them a 3D effect. Pilih objek, lalu pilih jenis objek untuk memberinya efek 3D. Extrude & Bevel… Ekstrusi & bevel… Revolve… Revolusi… Inflate… Gelembungkan… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 7a0cbaa72..62e34497e 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -84,6 +84,11 @@ 3 Color Job… Lavoro a 3 colori… 30° Angles Angoli di 30° 3D and Materials 3D e materiali + Plane Piano + Extrude Estrusione + Revolve Rivoluzione + Inflate Gonfia + Select objects, then pick an object type to give them a 3D effect. Seleziona gli oggetti, poi scegli un tipo di oggetto per dar loro un effetto 3D. 3D Ribbon Nastro 3D 45° Angles Angoli di 45° 60° Angles Angoli di 60° diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 827460f5d..c39d9a307 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -487,6 +487,11 @@ digits Malayalam マラヤーラム数字 Document Raster Effects Settings… ドキュメントのラスタライズ効果設定… Vector Effects ベクター効果 3D and Materials 3D とマテリアル + Plane 平面 + Extrude 押し出し + Revolve 回転体 + Inflate 膨張 + Select objects, then pick an object type to give them a 3D effect. オブジェクトを選択してから、オブジェクトの種類を選んで 3D 効果を適用してください。 Extrude & Bevel… 押し出しとベベル… Revolve… 回転体… Inflate… 膨張… diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 009eb7601..49b8f680b 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -380,6 +380,11 @@ Document Raster Effects Settings… დოკუმენტის რასტრული ეფექტების პარამეტრები… Vector Effects ვექტორული ეფექტები 3D and Materials 3D და მასალები + Plane სიბრტყე + Extrude ამოწევა + Revolve ბრუნვა + Inflate გაბერვა + Select objects, then pick an object type to give them a 3D effect. აირჩიეთ ობიექტები, შემდეგ აირჩიეთ ობიექტის ტიპი, რომ მათ 3D ეფექტი მიეცეთ. Extrude & Bevel… ამოწევა და წაკვეთა… Revolve… ბრუნვა… Inflate… გაბერვა… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index d584bcf0d..42fbefc5f 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -480,6 +480,11 @@ digits Malayalam 말라얄람어 Document Raster Effects Settings… 문서 래스터 효과 설정… Vector Effects 벡터 효과 3D and Materials 3D 및 재질 + Plane 평면 + Extrude 돌출 + Revolve 회전체 + Inflate 부풀리기 + Select objects, then pick an object type to give them a 3D effect. 개체를 선택한 다음 개체 유형을 골라 3D 효과를 적용하십시오. Extrude & Bevel… 돌출 및 경사… Revolve… 회전체… Inflate… 부풀리기… diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 12c84ce43..ebcf89356 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -428,6 +428,11 @@ Document Raster Effects Settings… Instellingen voor documentrastereffecten… Vector Effects Vectoreffecten 3D and Materials 3D en materialen + Plane Vlak + Extrude Extruderen + Revolve Draaien + Inflate Opblazen + Select objects, then pick an object type to give them a 3D effect. Selecteer objecten en kies dan een objecttype om ze een 3D-effect te geven. Extrude & Bevel… Extruderen en afschuinen… Revolve… Draaien… Inflate… Opblazen… diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 94af2b1ac..512690d39 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -416,6 +416,11 @@ Document Raster Effects Settings… Ustawienia efektów rastrowych dokumentu… Vector Effects Efekty wektorowe 3D and Materials 3D i materiały + Plane Płaszczyzna + Extrude Wyciągnięcie + Revolve Obrót + Inflate Nadmuchanie + Select objects, then pick an object type to give them a 3D effect. Zaznacz obiekty, a następnie wybierz typ obiektu, aby nadać im efekt 3D. Extrude & Bevel… Wyciągnięcie i fazowanie… Revolve… Obrót… Inflate… Nadmuchanie… diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index a7ec8d313..f17fb4464 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -96,6 +96,11 @@ 30° Angles Ângulos de 30° 3D Ribbon Fita 3D 3D and Materials 3D e Materiais + Plane Plano + Extrude Extrusão + Revolve Revolver + Inflate Inflar + Select objects, then pick an object type to give them a 3D effect. Selecione objetos e escolha um tipo de objeto para dar a eles um efeito 3D. 45° Angles Ângulos de 45° 60° Angles Ângulos de 60° 90° & 45° & 30° Angles Ângulos de 90° & 45° & 30° diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 5af458942..0f200c341 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -82,6 +82,11 @@ 3 Color Job… Задание с 3 цветами… 30° Angles Углы 30° 3D and Materials 3D и материалы + Plane Плоскость + Extrude Выдавливание + Revolve Вращение + Inflate Надувание + Select objects, then pick an object type to give them a 3D effect. Выделите объекты, затем выберите тип объекта, чтобы придать им 3D-эффект. 3D Ribbon 3D-лента 45° Angles Углы 45° 60° Angles Углы 60° diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 83fcf6ac0..5ed1cf9ca 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -72,6 +72,11 @@ 3 Color Job… 3-färgsjobb… 30° Angles 30°-vinklar 3D and Materials 3D och material + Plane Plan + Extrude Extrudera + Revolve Rotera runt axel + Inflate Blås upp + Select objects, then pick an object type to give them a 3D effect. Markera objekt och välj sedan en objekttyp för att ge dem en 3D-effekt. 3D Ribbon 3D-band 45° Angles 45°-vinklar 60° Angles 60°-vinklar diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 264c7020e..c4ec25012 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -69,6 +69,11 @@ 3 Color Job… 3 Renkli İş… 30° Angles 30° Açılar 3D and Materials 3D ve Malzemeler + Plane Düzlem + Extrude Ekstrüzyon + Revolve Döndür + Inflate Şişir + Select objects, then pick an object type to give them a 3D effect. Nesneleri seçin, ardından onlara 3D efekti vermek için bir nesne türü seçin. 3D Ribbon 3D Şerit 45° Angles 45° Açılar 60° Angles 60° Açılar diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0e1195938..ae131c2e5 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -78,6 +78,11 @@ 3 Color Job… Завдання з 3 кольорами… 30° Angles Кути 30° 3D and Materials 3D та матеріали + Plane Площина + Extrude Видавлювання + Revolve Обертання + Inflate Надування + Select objects, then pick an object type to give them a 3D effect. Виділіть об’єкти, потім виберіть тип об’єкта, щоб надати їм 3D-ефект. 3D Ribbon 3D-стрічка 45° Angles Кути 45° 60° Angles Кути 60° diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 72ebde638..f36b874d1 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -83,6 +83,11 @@ 3 x 3 Average Trung bình 3 x 3 30° Angles Góc 30° 3D and Materials 3D và vật liệu + Plane Mặt phẳng + Extrude Kéo dài + Revolve Xoay tròn + Inflate Phình + Select objects, then pick an object type to give them a 3D effect. Chọn đối tượng, rồi chọn một kiểu đối tượng để tạo hiệu ứng 3D cho chúng. 3D Ribbon Dải 3D 45° Angles Góc 45° 5 x 5 Average Trung bình 5 x 5 diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d4e927749..dafd7dd3c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -26,6 +26,11 @@ 3 Color Job… 3 色作业… 30° Angles 30° 角 3D and Materials 3D 与材质 + Plane 平面 + Extrude 突出 + Revolve 回转 + Inflate 膨胀 + Select objects, then pick an object type to give them a 3D effect. 选择对象,然后选取一种对象类型,为其添加 3D 效果。 3D Ribbon 3D 缎带 45° Angles 45° 角 60° Angles 60° 角 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index e052bbc5d..10f0df2b9 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -34,6 +34,11 @@ 3 Color Job… 3 色印刷作業… 30° Angles 30° 角度 3D and Materials 3D 與材質 + Plane 平面 + Extrude 突出 + Revolve 迴轉 + Inflate 膨脹 + Select objects, then pick an object type to give them a 3D effect. 選取物件,然後挑選一種物件類型,為其加上 3D 效果。 3D Ribbon 3D 緞帶 45° Angles 45° 角度 60° Angles 60° 角度 diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index 3a55ab37c..752901c04 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -629,6 +629,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { ]; let lang = crate::i18n::current(); let mut panels = vec![ + panel("3D and Materials", crate::panels::three_d::ID), panel("Actions", "actions"), panel("Align", "align"), panel("Appearance", "appearance"), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 7dd258a68..84eaf8791 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -43,6 +43,7 @@ pub mod swatches; pub mod symbols; pub mod tabs; pub mod text_styles; +pub mod three_d; pub mod transform; pub mod transparency; pub mod variables; @@ -179,6 +180,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { links::ID => links::show(app, ui), asset_export::ID => asset_export::show(app, ui), css_properties::ID => css_properties::show(app, ui), + three_d::ID => three_d::show(app, ui), crate::addons::cleanup::ID => crate::addons::cleanup::show(app, ui), crate::addons::color::ID => crate::addons::color::show(app, ui), crate::addons::pathtools::ID => crate::addons::pathtools::show(app, ui), diff --git a/crates/ui-egui/src/panels/tests_three_d.rs b/crates/ui-egui/src/panels/tests_three_d.rs new file mode 100644 index 000000000..44ed256f6 --- /dev/null +++ b/crates/ui-egui/src/panels/tests_three_d.rs @@ -0,0 +1,347 @@ +//! The 3D and Materials panel drawn headless: its Window menu entry, the object types applying or +//! changing a 3D effect, the dialogs' controls for the applied effect, and one undo step for a +//! drag or a typed value. +#![allow(clippy::unwrap_used, clippy::panic)] + +use serde_json::{Map, Value, json}; +use vectorcraft_engine::Session; + +use super::{ID, Target, changed_settings, send, set_type, show, target}; +use crate::tests_labels::painted_text; +use crate::{Services, VectorcraftApp}; + +/// An app with a selected blue square. +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + app.run("paint.setFill", json!({"color": "#338ad6"})).unwrap(); + app +} + +fn undo_len(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().history.undo.len() +} + +fn effect(app: &VectorcraftApp) -> (String, Value) { + let t = target(app).unwrap(); + (t.effect, t.params) +} + +#[test] +fn the_window_menu_opens_the_panel() { + let mut app = app(); + assert!(crate::state::ICON_PANELS.iter().any(|(id, label, _)| *id == ID && *label == "3D and Materials")); + app.run("window.panel", json!({"panel": ID})).unwrap(); + assert!(crate::panel_docking::showing(&app.ui, ID)); + app.run("window.panel", json!({"panel": "3D and Materials"})).unwrap(); +} + +#[test] +fn without_a_3d_effect_the_panel_offers_the_object_types() { + let mut app = app(); + let text = painted_text(&mut app, show); + for label in ["Plane", "Extrude", "Revolve", "Inflate", "Select objects, then pick an object type"] { + assert!(text.contains(label), "{label} in {text}"); + } + app.run("select.none", json!({})).unwrap(); + let text = painted_text(&mut app, show); + assert!(text.contains("Select objects, then pick an object type"), "{text}"); +} + +#[test] +fn an_object_type_applies_its_effect_then_changes_it_in_place() { + let mut app = app(); + let history = undo_len(&app); + set_type(&mut app, "extrude").unwrap(); + assert_eq!(effect(&app).0, "threeD.extrude"); + assert_eq!(undo_len(&app), history + 1); + app.run("effect.setParams", json!({"index": 0, "item": null, "params": {"rotationX": 15.0, "depth": 70.0}})).unwrap(); + for (kind, id) in [("inflate", "threeD.inflate"), ("revolve", "threeD.revolve"), ("plane", "threeD.rotate"), ("extrude", "threeD.extrude")] { + let history = undo_len(&app); + set_type(&mut app, kind).unwrap(); + let (now, params) = effect(&app); + assert_eq!(now, id); + assert_eq!(params["rotationX"], 15.0, "{kind}"); + assert_eq!(undo_len(&app), history + 1, "{kind}"); + // The object keeps one effect: the type changed in place. + assert_eq!(app.session.active().unwrap().doc.node(app.session.active().unwrap().selection.objects[0]).unwrap().appearance.effects.len(), 1); + } + // The type it already has runs nothing. + let history = undo_len(&app); + set_type(&mut app, "extrude").unwrap(); + assert_eq!(undo_len(&app), history); + assert!(set_type(&mut app, "cube").unwrap_err().contains("must be one of plane, extrude, revolve, inflate")); + assert_eq!(undo_len(&app), history); +} + +/// The ids of the effects of objects `ids`, in order. +fn effect_ids(app: &VectorcraftApp, ids: &[u64]) -> Vec> { + let st = app.session.active().unwrap(); + ids.iter().map(|id| st.doc.node(vectorcraft_doc::NodeId(*id)).unwrap().appearance.effects.iter().map(|e| e.id.clone()).collect()).collect() +} + +#[test] +fn an_object_type_reaches_every_selected_object_in_one_undo_step() { + let mut app = app(); + let a = app.session.active().unwrap().selection.objects[0].0; + set_type(&mut app, "extrude").unwrap(); + let b = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "threeD.inflate", "item": null})).unwrap(); + let c = app.run("shape.rectangle", json!({"x": 100, "y": 250, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + // The first object already has the type; the second has another; the third has none. + for order in [[a, b, c], [c, b, a]] { + app.run("select.set", json!({"ids": order})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let history = undo_len(&app); + set_type(&mut app, "extrude").unwrap(); + assert_eq!(effect_ids(&app, &[a, b, c]), vec![vec!["threeD.extrude".to_string()]; 3], "{order:?}"); + assert_eq!(undo_len(&app), history + 1, "{order:?}"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before, "{order:?}"); + } + // Every selected object already has the type: nothing is recorded. + app.run("select.set", json!({"ids": [a, b, c]})).unwrap(); + set_type(&mut app, "extrude").unwrap(); + let history = undo_len(&app); + set_type(&mut app, "extrude").unwrap(); + assert_eq!(undo_len(&app), history); +} + +#[test] +fn a_drag_ends_when_the_selection_goes_away() { + let mut app = app(); + set_type(&mut app, "extrude").unwrap(); + let ctx = egui::Context::default(); + let history = undo_len(&app); + send(&mut app, &ctx, Map::from_iter([("depth".to_string(), json!(90.0))]), true); + assert!(app.session.in_interaction()); + app.session.active_mut().unwrap().selection.objects.clear(); + ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui)).textures_delta.clear(); + assert!(!app.session.in_interaction()); + assert_eq!(undo_len(&app), history + 1); +} + +#[test] +fn with_nothing_selected_an_object_type_changes_nothing() { + let mut app = app(); + app.run("select.none", json!({})).unwrap(); + let history = undo_len(&app); + assert!(set_type(&mut app, "plane").is_err()); + assert_eq!(undo_len(&app), history); +} + +#[test] +fn the_panel_shows_the_dialog_controls_of_each_type() { + let mut app = app(); + for (kind, label) in [("extrude", "Depth"), ("inflate", "Volume"), ("revolve", "Revolve angle"), ("plane", "Rotation X")] { + set_type(&mut app, kind).unwrap(); + let text = painted_text(&mut app, show); + assert!(text.contains(label), "{kind}: {label} in {text}"); + assert!(text.contains("Lighting"), "{kind}: {text}"); + // Drawing the panel is no edit. + let history = undo_len(&app); + painted_text(&mut app, show); + assert_eq!(undo_len(&app), history, "{kind}"); + } + // The dialog's artboard-axis toggle has no artboard gizmo in the panel. + set_type(&mut app, "revolve").unwrap(); + assert!(!painted_text(&mut app, show).contains("Show axis")); +} + +#[test] +fn a_drag_previews_and_records_one_undo_step_when_released() { + let mut app = app(); + set_type(&mut app, "extrude").unwrap(); + let ctx = egui::Context::default(); + let history = undo_len(&app); + for depth in [60.0, 80.0, 120.0] { + send(&mut app, &ctx, Map::from_iter([("depth".to_string(), json!(depth))]), true); + assert_eq!(effect(&app).1["depth"], depth); + } + // Another setting in the same drag keeps the earlier one. + send(&mut app, &ctx, Map::from_iter([("twist".to_string(), json!(45.0))]), true); + assert_eq!(effect(&app).1["depth"], 120.0); + assert_eq!(undo_len(&app), history); + // Released without a change in the last frame: the drag commits. + send(&mut app, &ctx, Map::new(), false); + assert_eq!(undo_len(&app), history + 1); + let (_, params) = effect(&app); + assert_eq!((params["depth"].clone(), params["twist"].clone()), (json!(120.0), json!(45.0))); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(effect(&app).1["depth"], 50.0); + // A typed value is one undo step at once. + send(&mut app, &ctx, Map::from_iter([("taper".to_string(), json!(60.0))]), false); + assert_eq!(effect(&app).1["taper"], 60.0); + assert_eq!(undo_len(&app), history + 1); + // Nothing changed and no drag: nothing runs. + send(&mut app, &ctx, Map::new(), false); + assert_eq!(undo_len(&app), history + 1); +} + +#[test] +fn a_change_reaches_each_selected_3d_effect_and_no_other_effect() { + let mut app = app(); + let a = app.session.active().unwrap().selection.objects[0].0; + set_type(&mut app, "extrude").unwrap(); + // A second object with Inflate after a drop shadow, a third with only a drop shadow. + let b = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "stylize.dropShadow", "item": null})).unwrap(); + app.run("effect.apply", json!({"effect": "threeD.inflate", "item": null})).unwrap(); + let c = app.run("shape.rectangle", json!({"x": 100, "y": 250, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "stylize.dropShadow", "item": null})).unwrap(); + app.run("select.set", json!({"ids": [a, b, c]})).unwrap(); + let shadow = |app: &VectorcraftApp, id: u64| { + app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params.clone() + }; + let (b_shadow, c_shadow) = (shadow(&app, b), shadow(&app, c)); + let history = undo_len(&app); + let ctx = egui::Context::default(); + // `bevel` is an Extrude & Bevel setting only; `depth` both effects take. + send(&mut app, &ctx, Map::from_iter([("depth".to_string(), json!(75.0)), ("bevel".to_string(), json!("classic"))]), true); + send(&mut app, &ctx, Map::new(), false); + assert_eq!(undo_len(&app), history + 1); + let st = app.session.active().unwrap(); + let fx = |id: u64| st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects.clone(); + assert_eq!(fx(a)[0].params["depth"], 75.0); + assert_eq!(fx(a)[0].params["bevel"], "classic"); + assert_eq!(fx(b)[1].params["depth"], 75.0); + assert!(fx(b)[1].params.get("bevel").is_none()); + assert_eq!(fx(b)[0].params, b_shadow); + assert_eq!(fx(c)[0].params, c_shadow); +} + +#[test] +fn the_panel_finds_a_3d_effect_on_a_fill() { + let mut app = app(); + app.run("effect.apply", json!({"effect": "threeD.inflate", "item": 0})).unwrap(); + let t = target(&app).unwrap(); + assert_eq!((t.item, t.index, t.effect.as_str()), (Some(0), 0, "threeD.inflate")); + set_type(&mut app, "extrude").unwrap(); + assert_eq!(target(&app).map(|t: Target| (t.item, t.effect)), Some((Some(0), "threeD.extrude".to_string()))); +} + +#[test] +fn settings_the_selected_effects_disagree_on_are_unset_so_a_reset_reaches_them_all() { + let mut app = app(); + let a = app.session.active().unwrap().selection.objects[0].0; + set_type(&mut app, "extrude").unwrap(); + let b = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"depth": 80.0}})).unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let mut fields = target(&app).map(|t| vectorcraft_effects::merged_params(&t.effect, &t.params)).unwrap().as_object().cloned().unwrap(); + super::mark_mixed(&app, &mut fields); + assert_eq!(fields["depth"], Value::Null); + assert_eq!(fields["twist"], 0.0); + // Resetting Depth to its default (the first object's value) is a change for both objects. + let before = Value::Object(fields.clone()); + fields.insert("depth".into(), json!(50.0)); + let changed = changed_settings(&before, &Value::Object(fields)); + assert_eq!(Value::Object(changed.clone()), json!({"depth": 50.0})); + send(&mut app, &egui::Context::default(), changed, false); + let st = app.session.active().unwrap(); + for id in [a, b] { + assert_eq!(st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params["depth"], 50.0); + } +} + +#[test] +fn a_light_list_edit_reaches_only_the_objects_with_the_same_lights() { + let mut app = app(); + let two = json!([{"color": "#ff8040"}, {"color": "#4080ff", "rotation": -35.0}]); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"lights": two}})).unwrap(); + let a = app.session.active().unwrap().selection.objects[0].0; + let add = |app: &mut VectorcraftApp, x: f64, lights: Value| { + let id = app.run("shape.rectangle", json!({"x": x, "y": 250, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"lights": lights}})).unwrap(); + id + }; + let b = add(&mut app, 50.0, json!([{"color": "#00ff00"}])); + let c = add(&mut app, 200.0, two.clone()); + app.run("select.set", json!({"ids": [a, b, c]})).unwrap(); + let lights = |app: &VectorcraftApp, id: u64| { + app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params["lights"].clone() + }; + let b_before = lights(&app, b); + let three = json!([{"color": "#ff8040"}, {"color": "#4080ff", "rotation": -35.0}, {"color": "#ffffff", "rotation": 0.0, "height": 90.0}]); + let history = undo_len(&app); + send(&mut app, &egui::Context::default(), Map::from_iter([("lights".to_string(), three.clone())]), false); + assert_eq!(undo_len(&app), history + 1); + assert_eq!(lights(&app, a).as_array().map(Vec::len), Some(3)); + assert_eq!(lights(&app, c), lights(&app, a)); + assert_eq!(lights(&app, b), b_before); +} + +#[test] +fn a_drag_keeps_its_objects_and_a_new_selection_starts_a_new_step() { + let mut app = app(); + let a = app.session.active().unwrap().selection.objects[0]; + set_type(&mut app, "extrude").unwrap(); + let b = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + let b = vectorcraft_doc::NodeId(b); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null})).unwrap(); + app.run("select.set", json!({"ids": [a.0]})).unwrap(); + let history = undo_len(&app); + let ctx = egui::Context::default(); + send(&mut app, &ctx, Map::from_iter([("depth".to_string(), json!(90.0))]), true); + // The selection changes during the drag: the drag on `a` is committed, and `b` starts its own. + app.session.active_mut().unwrap().selection.objects = vec![b]; + send(&mut app, &ctx, Map::from_iter([("depth".to_string(), json!(70.0))]), true); + send(&mut app, &ctx, Map::new(), false); + let depth = |app: &VectorcraftApp, id| app.session.active().unwrap().doc.node(id).unwrap().appearance.effects[0].params["depth"].clone(); + assert_eq!(depth(&app, a), 90.0); + assert_eq!(depth(&app, b), 70.0); + assert_eq!(undo_len(&app), history + 2); +} + +#[test] +fn a_light_drag_keeps_the_objects_whose_lights_matched_when_it_started() { + let mut app = app(); + let one = |color: &str| json!([{"color": color}]); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"lights": one("#ff0000")}})).unwrap(); + let a = app.session.active().unwrap().selection.objects[0].0; + let b = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"lights": one("#00ff00")}})).unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let ctx = egui::Context::default(); + // The first object's list passes through the second's value during the drag. + send(&mut app, &ctx, Map::from_iter([("lights".to_string(), one("#00ff00"))]), true); + send(&mut app, &ctx, Map::from_iter([("lights".to_string(), one("#0000ff"))]), true); + send(&mut app, &ctx, Map::new(), false); + let lights = + |id: u64| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params["lights"].clone(); + assert_eq!(lights(a), one("#0000ff")); + assert_eq!(lights(b), one("#00ff00")); +} + +#[test] +fn a_drag_ends_when_the_panel_is_no_longer_drawn() { + let mut app = app(); + set_type(&mut app, "extrude").unwrap(); + let ctx = egui::Context::default(); + let history = undo_len(&app); + ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui)).textures_delta.clear(); + send(&mut app, &ctx, Map::from_iter([("depth".to_string(), json!(90.0))]), true); + // A pass that draws the panel keeps the drag; the first pass that does not draw it ends it. + ctx.run_ui(egui::RawInput::default(), |ui| { + // Drawn with the pointer still down: only the mark `show` leaves. + crate::panels::set_pstate(ui.ctx(), super::DRAWN, Some(super::root_pass(ui.ctx()))); + super::end_stale_drag(&mut app, ui.ctx()); + }) + .textures_delta + .clear(); + assert!(app.session.in_interaction()); + ctx.run_ui(egui::RawInput::default(), |ui| super::end_stale_drag(&mut app, ui.ctx())).textures_delta.clear(); + assert!(!app.session.in_interaction()); + assert_eq!(undo_len(&app), history + 1); + assert_eq!(effect(&app).1["depth"], 90.0); +} + +#[test] +fn changed_settings_lists_only_new_values() { + let before = json!({"depth": 50.0, "cap": true, "bevel": "none"}); + let after = json!({"depth": 60.0, "cap": true, "bevel": "classic", "twist": 0.0}); + let changed = changed_settings(&before, &after); + assert_eq!(Value::Object(changed), json!({"depth": 60.0, "bevel": "classic", "twist": 0.0})); + assert!(changed_settings(&after, &after).is_empty()); +} diff --git a/crates/ui-egui/src/panels/three_d.rs b/crates/ui-egui/src/panels/three_d.rs new file mode 100644 index 000000000..cf6515396 --- /dev/null +++ b/crates/ui-egui/src/panels/three_d.rs @@ -0,0 +1,272 @@ +//! Window › 3D and Materials: edits the selection's 3D effect in place. The object types (Plane, +//! Extrude, Revolve, Inflate) apply a 3D effect to a selection without one (`effect.apply`) or +//! change the applied one (`effect.setParams` with `threeDType`). Below them are the 3D effect +//! dialogs' own Object, Rotation and Lighting controls; each change runs `effect.setParams` as one +//! undo step, and a drag previews and commits once, when the pointer is released. + +use egui::Ui; +use serde_json::{Map, Value, json}; +use vectorcraft_doc::{Effect, Node}; +use vectorcraft_effects::{THREE_D_TYPES, is_three_d}; + +use super::{live_run, pstate, set_pstate}; +use crate::VectorcraftApp; +use crate::state::Dialog; +use crate::widgets::{self, Live, dim_label}; + +/// The panel's id (`window.panel`). +pub const ID: &str = "threeD"; + +/// The name of the undo step a change makes, as the effect dialogs' commits name it. +const LABEL: &str = "Effect Options"; + +/// The selection's 3D effect: the appearance item whose effects hold it (`None`: the object's +/// own effects), its index there, and its id and parameters. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Target { + pub item: Option, + pub index: usize, + pub effect: String, + pub params: Value, +} + +/// The 3D effect of the first selected object: its own effects first, then its fills' and +/// strokes'. `None` without a selection or without a 3D effect. +pub(crate) fn target(app: &VectorcraftApp) -> Option { + let st = app.session.active()?; + let n = st.selection.objects.first().and_then(|id| st.doc.node(*id))?; + find(n) +} + +fn find(n: &Node) -> Option { + let in_list = |fx: &[Effect], item: Option| { + fx.iter().position(|e| is_three_d(&e.id)).map(|index| Target { item, index, effect: fx[index].id.clone(), params: fx[index].params.clone() }) + }; + in_list(&n.appearance.effects, None).or_else(|| n.appearance.items.iter().enumerate().find_map(|(k, item)| in_list(item.effects(), Some(k)))) +} + +/// The panel's object type of effect `id` (`plane`, `extrude`, `revolve` or `inflate`). +pub(crate) fn type_of(id: &str) -> Option<&'static str> { + THREE_D_TYPES.iter().find(|(_, effect)| *effect == id).map(|(kind, _)| *kind) +} + +/// Picks object type `kind` for each selected object: changes its 3D effect into that type, or +/// applies that type's effect to it when it has none, all as one undo step. An object whose 3D +/// effect already has that type is left as it is (`effect.setParams` records nothing for it). +pub(crate) fn set_type(app: &mut VectorcraftApp, kind: &str) -> Result<(), String> { + let Some(&(_, effect)) = THREE_D_TYPES.iter().find(|(k, _)| *k == kind) else { + let key = "threeDType"; + return Err(format!("`{key}` must be one of {}", THREE_D_TYPES.map(|(k, _)| k).join(", "))); + }; + let steps: Vec<(&str, Value)> = app.session.active().map_or_else(Vec::new, |st| { + st.selection + .objects + .iter() + .filter_map(|id| st.doc.node(*id).map(|n| (id.0, find(n)))) + .map(|(id, t)| match t { + Some(t) => ("effect.setParams", json!({"ids": [id], "index": t.index, "item": t.item, "threeDType": kind})), + None => ("effect.apply", json!({"ids": [id], "effect": effect, "item": null})), + }) + .collect() + }); + match steps.as_slice() { + // Without a selection, `effect.apply` says why nothing changed. + [] => app.run("effect.apply", json!({"effect": effect, "item": null})).map(|_| ()), + [(id, p)] => app.run(id, p.clone()).map(|_| ()), + _ => { + app.run("edit.undoGroup.begin", json!({"label": LABEL}))?; + for (id, p) in &steps { + if let Err(e) = app.run(id, p.clone()) { + app.run("edit.undoGroup.end", json!({"cancel": true})).ok(); + return Err(e); + } + } + app.run("edit.undoGroup.end", json!({})).map(|_| ()) + } + } +} + +/// Commits the drag in progress, when the panel has one. +fn finish(app: &mut VectorcraftApp, ctx: &egui::Context) { + if !pstate::>(ctx, PENDING).is_empty() { + app.session.commit_interaction().ok(); + app.sync_views(); + } + set_pstate(ctx, PENDING, Map::::new()); + set_pstate(ctx, PLAN, None::); +} + +/// What a change goes to, taken when it starts (before any preview): the selected objects' ids, +/// and each selected object with a 3D effect, the effect, and whether its list of lights matches +/// the first one's (a light list edited in the panel replaces only those). +#[derive(Clone, Debug)] +struct Plan { + selection: Vec, + targets: Vec<(u64, Target, bool)>, +} + +fn plan(app: &VectorcraftApp) -> Plan { + let Some(st) = app.session.active() else { return Plan { selection: vec![], targets: vec![] } }; + let selection: Vec = st.selection.objects.iter().map(|id| id.0).collect(); + let found: Vec<(u64, Target)> = st.selection.objects.iter().filter_map(|id| Some((id.0, find(st.doc.node(*id)?)?))).collect(); + let lights = |t: &Target| vectorcraft_effects::merged_params(&t.effect, &t.params).get("lights").cloned(); + let first = found.first().map(|(_, t)| lights(t)); + let targets = found + .into_iter() + .map(|(id, t)| { + let same = first.as_ref() == Some(&lights(&t)); + (id, t, same) + }) + .collect(); + Plan { selection, targets } +} + +/// Sends the settings `changed` to the 3D effect of each selected object (only the settings its +/// effect takes; objects without a 3D effect are left as they are): previews them while the +/// pointer is down (`dragging`) and commits them as one undo step when it is released. Settings +/// changed earlier in the same drag are sent again with them, to the objects the drag started +/// with, since each preview starts again from the document as it was before the drag. A drag +/// whose selection changes is committed first, and the change starts a new one. +pub(crate) fn send(app: &mut VectorcraftApp, ctx: &egui::Context, changed: Map, dragging: bool) { + if changed.is_empty() { + // A drag that ended without a change in its last frame is committed now. + if !dragging { + finish(app, ctx); + } + return; + } + let selection: Vec = app.session.active().map(|st| st.selection.objects.iter().map(|id| id.0).collect()).unwrap_or_default(); + let mut held: Option = pstate(ctx, PLAN); + if held.as_ref().is_some_and(|p| p.selection != selection) { + finish(app, ctx); + held = None; + } + let plan = held.unwrap_or_else(|| plan(app)); + let mut pending: Map = pstate(ctx, PENDING); + pending.extend(changed); + let steps: Vec = plan + .targets + .iter() + .filter_map(|(id, t, same_lights)| { + let takes = vectorcraft_effects::default_params(&t.effect).unwrap_or(Value::Null); + let params: Map = pending + .iter() + .filter(|(k, _)| takes.get(k.as_str()).is_some() && (k.as_str() != "lights" || *same_lights)) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + (!params.is_empty()) + .then(|| json!({"command": "effect.setParams", "params": {"ids": [id], "index": t.index, "item": t.item, "params": params}})) + }) + .collect(); + let phase = if dragging { Live::Dragging } else { Live::Released }; + match steps.as_slice() { + [] => {} + [step] => live_run(app, LABEL, "effect.setParams", step["params"].clone(), phase), + _ => live_run(app, LABEL, "command.batch", json!({"label": LABEL, "commands": steps}), phase), + } + if dragging { + set_pstate(ctx, PENDING, pending); + set_pstate(ctx, PLAN, Some(plan)); + } else { + set_pstate(ctx, PENDING, Map::::new()); + set_pstate(ctx, PLAN, None::); + } +} + +/// Panel state key of the [`Plan`] of the drag in progress. +const PLAN: &str = "three-d-plan"; +/// Panel state key of the pass the panel was last drawn in. +const DRAWN: &str = "three-d-drawn"; + +/// Commits the panel's drag in progress when the panel was not drawn in this pass (it was closed +/// or hidden during the drag), so the drag is one undo step and no preview stays open into a later +/// pass. The app runs this at the end of every pass, after the panels. +pub(crate) fn end_stale_drag(app: &mut VectorcraftApp, ctx: &egui::Context) { + let drawn: Option = pstate(ctx, DRAWN); + if drawn.is_some_and(|pass| pass != root_pass(ctx)) { + finish(app, ctx); + set_pstate(ctx, DRAWN, None::); + } +} + +/// The main window's pass in progress. A floating window draws inside it and counts its own +/// passes, so the panel's mark uses this one wherever the panel draws. +fn root_pass(ctx: &egui::Context) -> u64 { + ctx.cumulative_pass_nr_for(egui::ViewportId::ROOT) +} + +/// Panel state key of the settings changed during the drag in progress. +const PENDING: &str = "three-d-pending"; +/// Panel state key of the controls' own state (the open tab, the selected light). +const UI_STATE: &str = "three-d-ui"; + +/// The 3D and Materials panel. +pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + let ctx = ui.ctx().clone(); + set_pstate(&ctx, DRAWN, Some(root_pass(&ctx))); + let target = target(app); + let selected = app.session.active().is_some_and(|st| !st.selection.objects.is_empty()); + let current = target.as_ref().and_then(|t| type_of(&t.effect)); + let labels = [tl!("Plane"), tl!("Extrude"), tl!("Revolve"), tl!("Inflate")]; + let at = current.and_then(|kind| THREE_D_TYPES.iter().position(|(k, _)| *k == kind)).unwrap_or(usize::MAX); + let picked = ui.add_enabled_ui(selected, |ui| widgets::tab_bar(ui, &labels, at)).inner; + if let Some((kind, _)) = picked.and_then(|i| THREE_D_TYPES.get(i)) + && let Err(e) = set_type(app, kind) + { + app.ui.status = e; + } + ui.add_space(8.0); + let Some(t) = target else { + // The selection changed during a drag: the drag ends with what it previewed. + finish(app, &ctx); + dim_label(ui, tl!("Select objects, then pick an object type to give them a 3D effect.")); + return; + }; + // The applied effect's settings in the dialog's form, with the controls' own state. + let mut fields = vectorcraft_effects::merged_params(&t.effect, &t.params).as_object().cloned().unwrap_or_default(); + mark_mixed(app, &mut fields); + fields.extend(pstate::>(&ctx, UI_STATE)); + fields.insert("__effect".into(), json!(t.effect)); + fields.insert("__panel".into(), json!(true)); + let mut d = Dialog { kind: ID.into(), fields }; + let before = crate::dialogs::form::params(&d); + crate::dialogs::revolve::controls(app, ui, &mut d); + let state: Map = d + .fields + .iter() + .filter(|(k, _)| k.starts_with("__") && !matches!(k.as_str(), "__effect" | "__panel")) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + set_pstate(&ctx, UI_STATE, state); + let after = crate::dialogs::form::params(&d); + let changed = changed_settings(&before, &after); + let dragging = ctx.input(|i| i.pointer.any_down()); + send(app, &ctx, changed, dragging); +} + +/// Sets to null each setting in `fields` (the first selected object's 3D effect) that the 3D effect +/// of another selected object has with another value. The controls then show it at its default +/// with its reset dot filled, and its reset dot, a menu choice, a checkbox click, or a number other +/// than the default shown is a change sent to every effect. The light list (`lights`, where null +/// is the single light) is left as it is. +pub(crate) fn mark_mixed(app: &VectorcraftApp, fields: &mut Map) { + let Some(st) = app.session.active() else { return }; + for other in st.selection.objects.iter().skip(1).filter_map(|id| find(st.doc.node(*id)?)) { + let other = vectorcraft_effects::merged_params(&other.effect, &other.params); + for (key, value) in fields.iter_mut().filter(|(k, _)| k.as_str() != "lights") { + if other.get(key.as_str()).is_some_and(|v| v != value) { + *value = Value::Null; + } + } + } +} + +/// The settings whose values differ between `before` and `after`, with their values in `after`. +pub(crate) fn changed_settings(before: &Value, after: &Value) -> Map { + let Some(after) = after.as_object() else { return Map::new() }; + after.iter().filter(|(k, v)| before.get(k.as_str()) != Some(*v)).map(|(k, v)| (k.clone(), v.clone())).collect() +} + +#[cfg(test)] +#[path = "tests_three_d.rs"] +mod tests; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 441892376..85f87b723 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -204,6 +204,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ (crate::panels::links::ID, "Links", "link"), (crate::panels::asset_export::ID, "Asset Export", "share-2"), (crate::panels::css_properties::ID, "CSS Properties", "globe"), + (crate::panels::three_d::ID, "3D and Materials", "dc-cube"), (crate::panels::variables::ID, "Variables", "chart-column"), (crate::addons::cleanup::ID, "Artwork Cleanup", "sparkles"), (crate::addons::color::ID, "Color Effects", "sun"), diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 2307add80..6fe07e086 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -180,7 +180,7 @@ VectorCraft choices; an original approximation, never compared with another appl |---|---| | Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | | Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | -| Materials panel, lighting, ray-traced render | one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; no materials, shadows or ray tracing | +| Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; no materials, shadows or ray tracing | | 3D (Classic): Extrude & Bevel, Revolve, Rotate | none | | Turntable (30.0 beta) | none | | Export 3D objects (OBJ, USDA, glTF) | none | diff --git a/docs/mcp.md b/docs/mcp.md index c6a648b7f..96c0f72dc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2223,6 +2223,21 @@ The document stores their numeric settings; manual edits remain possible through {"name":"run_command","arguments":{"command":"effect.lightingPreset","params":{"index":0,"item":null,"preset":"topLeft"}}} ``` +## 3D object type + +`effect.setParams {index: int, threeDType: "plane" | "extrude" | "revolve" | "inflate", params?, item?, ids?, aligned?}` +changes an applied 3D effect into Rotate, Extrude & Bevel, Revolve or Inflate in place, as the object types of +**Window › 3D and Materials** do ([3D and Materials panel](three-d.md#3d-and-materials-panel)). The settings both +effects take keep their values (rotation angles are wrapped into -180..180 when the new type is not Revolve), the +settings only the new effect takes start at its defaults, and `params` then merge into the new effect's settings, +checked as that effect's. It is one undo step. Any other `threeDType` value, or an effect at `index` that is not a +3D effect, is an error and changes nothing. The type the effect already has, with no `params`, `visible` or +`warpStyle`, changes nothing, records no undo step and returns `{ids: []}`. + +```json +{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"threeDType":"inflate","params":{"volume":70}}}} +``` + ## Extrude & Bevel, Inflate and Rotate `effect.apply {effect: "threeD.extrude" | "threeD.inflate" | "threeD.rotate", params: {...}, ids?, item?}` applies diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4d82e8adb..723fc673a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -519,6 +519,7 @@ since 2026-10-01. ## Evidence: what ships today, by area - 3D lighting presets: `crates/effects/src/lighting_presets.rs`, `crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs` and the pointer tests in `crates/ui-egui/src/dialogs/revolve/tests.rs` cover recognition, atomic targeting, native round trips, preview, Cancel, reset and Undo. +- Window › 3D and Materials panel: `crates/ui-egui/src/panels/three_d.rs` applies a 3D effect by object type (Plane, Extrude, Revolve, Inflate), changes an applied one in place (`effect.setParams` `threeDType`) and edits it with the options dialog's Object, Rotation and Lighting controls, one undo step per change; `crates/engine/src/cmd/effectcmd/three_d_type_tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. No Materials tab. - **Status-bar pointer readout (2026-10-18):** uses the same effective ruler origin as the position fields, then formats the relative X/Y in General units. The absolute pointer position, document, selection, Undo/Redo and saved state are preserved. Three `chrome::tests::status_coordinates_*` painted-text regressions cover zero/positive/negative origins, unit conversion, global/artboard mode, active artboard, reset and missing pointer/document. Original headless status-bar captures were reviewed before and after the change. Scores and hours are unchanged. diff --git a/docs/three-d.md b/docs/three-d.md index 7cc339dff..93e831e01 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -14,7 +14,34 @@ Select a path or compound path, then choose **Effect › 3D and Materials › Ex artboard. Preview changes are temporary until OK; Cancel restores the document; OK is one undo step. An object takes one -3D effect: edit its options in the Appearance panel, or remove it before applying another. +3D effect: edit its options in the Appearance panel or the 3D and Materials panel, or remove it before applying +another. + +## 3D and Materials panel + +**Window › 3D and Materials** opens a panel that edits the 3D effect of the selection without a dialog, as the +public [3D and Materials panel options](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/3d-materials-panel-options.html) +and [Create 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-vector-artwork.html) +pages describe it. + +- The object types **Plane**, **Extrude**, **Revolve** and **Inflate** sit at the top. A type applies its effect + to each selected object that has no 3D effect (Plane is Rotate, Extrude is Extrude & Bevel), and changes the + 3D effect of each other selected object in place (`effect.setParams` with `threeDType`): the settings both + effects take keep their values, and the rest start at the new effect's defaults. One click is one undo step, + and an object whose effect already has the type does not change. +- Below them are the Object, Rotation and Lighting tabs of the effect's options dialog, with the same controls + and ranges, showing the 3D effect of the first selected object (its own effects first, then those of its fills + and strokes). A changed setting goes to the 3D effect of each selected object whose effect takes that setting; + other effects and objects without a 3D effect do not change. A setting the selected 3D effects hold with + different values shows its default with its reset dot filled. Its reset dot, a choice from its menu or a click + on its checkbox sets it on all of them; a typed or dragged number sets it when it differs from the default + shown. + The list of lights shows the first object's lights, and an edit to it (Add Light, Delete Light, or a setting + of one light in a list) reaches only the objects whose list of lights matches the first object's. +- Each change is one undo step. Dragging a value or a slider previews it on the artboard and records one step + when the pointer is released; a typed value is recorded when it is entered. Drawing the panel records nothing. +- The Materials tab, shadows and ray-traced rendering are not built (see below). The rotation and light rings on + the artboard are part of the options dialog only. ## Extrude & Bevel