3D and Materials: Window › 3D and Materials edits the selection's 3D effect in place

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
This commit is contained in:
dotCooCoo
2026-11-08 12:59:34 +07:00
committed by bflatastic
parent a71a6ca53d
commit ba25415792
43 changed files with 1047 additions and 17 deletions
+2 -2
View File
@@ -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;
+26
View File
@@ -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()))
}
+57 -8
View File
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<String> {
return vec![];
};
let Ok(targets) = effect_targets(s, p, item, index, C) else { return vec![] };
let effect = |&(id, at, index): &(NodeId, Option<usize>, 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>, 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<Option<&'static str>> {
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<Value> {
const C: &str = "effect.lightingPreset";
let patch = str_param(p, "preset")
@@ -450,8 +468,9 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result<Value> {
}
/// `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<Value> {
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<Value> {
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<String> {
@@ -476,21 +496,41 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
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, &params)) {
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, &params)) {
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<Value> {
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, &params), &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<Value> {
#[cfg(test)]
mod lighting_presets_tests;
#[cfg(test)]
mod three_d_type_tests;
#[cfg(test)]
mod tests;
@@ -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();
}
+2
View File
@@ -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);
}
+1 -1
View File
@@ -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;
+12 -4
View File
@@ -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));
}
});
+5
View File
@@ -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°
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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í…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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°
1 # Ελληνικά (el) — VectorCraft
66
67
68
69
70
71
72
73
74
75
76
+5
View File
@@ -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°
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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°
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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 लाइटिंग प्रीसेट
Can't render this file because it is too large.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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…
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
414
415
416
417
418
419
420
421
422
423
424
+5
View File
@@ -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°
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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… 膨張…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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… გაბერვა…
Can't render this file because it is too large.
+5
View File
@@ -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… 부풀리기…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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°
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -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°
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+5
View File
@@ -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
Can't render this file because it contains an unexpected character in line 5 and column 25.
+5
View File
@@ -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°
1 # Ukrainian (uk) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation.
78
79
80
81
82
83
84
85
86
87
88
+5
View File
@@ -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
1 # Vietnamese (vi) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
83
84
85
86
87
88
89
90
91
92
93
+5
View File
@@ -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° 角
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -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° 角度
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -629,6 +629,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
];
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"),
+2
View File
@@ -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),
+347
View File
@@ -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<Vec<String>> {
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());
}
+272
View File
@@ -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<usize>,
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<Target> {
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<Target> {
let in_list = |fx: &[Effect], item: Option<usize>| {
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::<Map<String, Value>>(ctx, PENDING).is_empty() {
app.session.commit_interaction().ok();
app.sync_views();
}
set_pstate(ctx, PENDING, Map::<String, Value>::new());
set_pstate(ctx, PLAN, None::<Plan>);
}
/// 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<u64>,
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<u64> = 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<String, Value>, 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<u64> = app.session.active().map(|st| st.selection.objects.iter().map(|id| id.0).collect()).unwrap_or_default();
let mut held: Option<Plan> = 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<String, Value> = pstate(ctx, PENDING);
pending.extend(changed);
let steps: Vec<Value> = plan
.targets
.iter()
.filter_map(|(id, t, same_lights)| {
let takes = vectorcraft_effects::default_params(&t.effect).unwrap_or(Value::Null);
let params: Map<String, Value> = 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::<String, Value>::new());
set_pstate(ctx, PLAN, None::<Plan>);
}
}
/// 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<u64> = pstate(ctx, DRAWN);
if drawn.is_some_and(|pass| pass != root_pass(ctx)) {
finish(app, ctx);
set_pstate(ctx, DRAWN, None::<u64>);
}
}
/// 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::<Map<String, Value>>(&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<String, Value> = 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<String, Value>) {
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<String, Value> {
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;
+1
View File
@@ -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"),
+1 -1
View File
@@ -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 |
+15
View File
@@ -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
+1
View File
@@ -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.
+28 -1
View File
@@ -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