From 36ec1dc0cac15fdbab8c90b60bff52b27f6f19a2 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 22:13:36 +0300 Subject: [PATCH] Format Shape: 3-D Format and 3-D Rotation controls shape.effects gains `threeD`: top and bottom bevel (preset, width, height), depth, contour, material, lighting, camera preset, X/Y/Z rotation, distance from ground, and reset. The settings are read from and written back to the shape's kept a:scene3d / a:sp3d XML (new cmd::three_d), so they render through the existing camera and bevel code and survive a PPTX save; extrusion and contour colours are kept as is. The `bevel` key used to set only Effects::bevel, which neither the renderer nor the PPTX writer reads, so a bevel set from the app vanished on save; it now goes through the same path. The Effects page of the Format Shape pane gets 3-D Format and 3-D Rotation sections. Co-authored-by: Cursor --- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/cmd/shape.rs | 17 +- crates/engine/src/cmd/three_d.rs | 304 +++++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 50 +++++ crates/ui-egui/src/panes.rs | 75 ++++++++ docs/ui-parity.md | 2 +- 6 files changed, 445 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/cmd/three_d.rs diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ce909b02..0e2f78a1 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -16,6 +16,7 @@ pub mod shape; pub mod slide; pub mod table; pub mod text; +pub mod three_d; pub mod transition; use deckcraft_geom::Xfrm; diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index b4c6aa4e..9fcc6c4b 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -60,7 +60,7 @@ pub fn specs() -> Vec { "Shape Effects", ["Shape Format", "Shape Styles"], None, - "{shadow?: none|outer|inner|perspective|{color, blur, dist, dir}, glow?: none|{color, radius}, softEdges?: pt, reflection?: none|tight|half|full, bevel?: none|circle|…, reset?, ids?}", + "{shadow?: none|outer|inner|perspective|{color, blur, dist, dir}, glow?: none|{color, radius}, softEdges?: pt, reflection?: none|tight|half|full, bevel?: none|circle|…, threeD?: {camera?: preset, rotX?, rotY?, rotZ?: deg, z?: pt, bevelTop?, bevelBottom?: none|circle|…, bevelTopW?, bevelTopH?, bevelBottomW?, bevelBottomH?, depth?, contour?: pt, material?, lighting?, reset?: true}, reset?, ids?}", has_selection, effects ), @@ -449,6 +449,7 @@ fn effects(s: &mut Session, p: &Value) -> Result { "full" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.9, dist: 4.0 }), _ => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }), }); + let three_d = p.get("threeD").filter(|v| v.is_object()); let bevel = str_param(p, "bevel").map(|b| if b == "none" { None } else { Some(b.to_string()) }); edit_shapes(s, p, "shape.effects", |sh| { if reset { @@ -481,8 +482,18 @@ fn effects(s: &mut Session, p: &Value) -> Result { if let Some(r) = &refl { e.reflection.clone_from(r); } - if let Some(b) = &bevel { - e.bevel.clone_from(b); + if bevel.is_some() || three_d.is_some() { + let mut s3 = e.raw3d.as_deref().map(super::three_d::Shape3d::parse).unwrap_or_default(); + if let Some(b) = &bevel { + s3.apply(&json!({"bevelTop": b.as_deref().unwrap_or("none")})); + } + if let Some(t) = three_d { + if t.get("reset").and_then(Value::as_bool) == Some(true) { + s3 = super::three_d::Shape3d::default(); + } + s3.apply(t); + } + super::three_d::store(e, &s3); } Ok(()) }) diff --git a/crates/engine/src/cmd/three_d.rs b/crates/engine/src/cmd/three_d.rs new file mode 100644 index 00000000..fe53450b --- /dev/null +++ b/crates/engine/src/cmd/three_d.rs @@ -0,0 +1,304 @@ +//! Format Shape ▸ 3-D Format and 3-D Rotation: a shape's `a:scene3d` / `a:sp3d`, kept as XML on +//! the model (`Effects::raw3d`), read into fields, changed, and written back. + +use serde_json::Value; + +/// Camera presets PowerPoint offers (3-D Rotation ▸ Presets). +pub const CAMERAS: &[&str] = &[ + "orthographicFront", + "isometricTopUp", + "isometricTopDown", + "isometricBottomUp", + "isometricBottomDown", + "isometricLeftUp", + "isometricLeftDown", + "isometricRightUp", + "isometricRightDown", + "obliqueTopLeft", + "obliqueTopRight", + "obliqueBottomLeft", + "obliqueBottomRight", + "perspectiveFront", + "perspectiveLeft", + "perspectiveRight", + "perspectiveAbove", + "perspectiveBelow", + "perspectiveAboveLeftFacing", + "perspectiveAboveRightFacing", + "perspectiveContrastingLeftFacing", + "perspectiveContrastingRightFacing", + "perspectiveHeroicLeftFacing", + "perspectiveHeroicRightFacing", + "perspectiveHeroicExtremeLeftFacing", + "perspectiveHeroicExtremeRightFacing", + "perspectiveRelaxed", + "perspectiveRelaxedModerately", +]; + +/// Bevel presets (`ST_BevelPresetType`). +pub const BEVELS: &[&str] = + &["circle", "relaxedInset", "cross", "coolSlant", "angle", "softRound", "convex", "slope", "divot", "riblet", "hardEdge", "artDeco"]; + +/// Materials (`ST_PresetMaterialType`). +pub const MATERIALS: &[&str] = + &["warmMatte", "matte", "plastic", "metal", "dkEdge", "softEdge", "flat", "legacyWireframe", "powder", "translucentPowder", "clear", "softmetal"]; + +/// Light rigs (`ST_LightRigType`) PowerPoint offers. +pub const LIGHTS: &[&str] = &[ + "threePt", + "balanced", + "soft", + "harsh", + "flood", + "contrasting", + "morning", + "sunrise", + "sunset", + "chilly", + "freezing", + "flat", + "twoPt", + "glow", + "brightRoom", +]; + +/// One bevel: preset, width and height in points. +#[derive(Clone, Debug, PartialEq)] +pub struct Bevel { + pub preset: String, + pub w: f64, + pub h: f64, +} + +/// The 3-D settings of a shape. +#[derive(Clone, Debug, PartialEq)] +pub struct Shape3d { + pub camera: String, + /// Camera rotation (X, Y, Z in degrees, as PowerPoint shows them), when set. + pub rot: Option<(f64, f64, f64)>, + pub light: String, + pub light_dir: String, + pub top: Option, + pub bottom: Option, + /// Depth (extrusion), contour width and distance from ground, in points. + pub depth: f64, + pub contour: f64, + pub z: f64, + pub material: String, + /// `a:extrusionClr` / `a:contourClr` XML, kept as is. + pub colors: String, +} + +impl Default for Shape3d { + fn default() -> Self { + Shape3d { + camera: "orthographicFront".into(), + rot: None, + light: "threePt".into(), + light_dir: "t".into(), + top: None, + bottom: None, + depth: 0.0, + contour: 0.0, + z: 0.0, + material: "warmMatte".into(), + colors: String::new(), + } + } +} + +/// The start tags of `raw` as (local name, tag text), in order. +fn tags(raw: &str) -> Vec<(&str, &str)> { + let mut out = Vec::new(); + let mut rest = raw; + while let Some(i) = rest.find('<') { + let Some(t) = rest.get(i + 1..) else { break }; + let end = t.find('>').unwrap_or(t.len()); + let tag = t.get(..end).unwrap_or(""); + rest = t.get(end..).unwrap_or(""); + if tag.starts_with('/') || tag.starts_with('?') || tag.starts_with('!') { + continue; + } + let name = tag.split(|c: char| c.is_whitespace() || c == '/').next().unwrap_or(""); + let local = name.rsplit(':').next().unwrap_or(name); + out.push((local, tag)); + } + out +} + +fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> { + let key = format!(" {name}=\""); + let s = tag.find(&key)? + key.len(); + let rest = tag.get(s..)?; + rest.get(..rest.find('"')?) +} + +fn num(tag: &str, name: &str) -> Option { + attr(tag, name).and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()) +} + +const EMU: f64 = 12700.0; +const ANGLE: f64 = 60000.0; + +/// An `a:`-prefixed element `local` (with its children) cut from `raw`, if there is one. +fn element(raw: &str, local: &str) -> Option { + let open = format!(""); + let i = raw.find(&open)?; + let j = raw.get(i..)?.find(&close)? + i + close.len(); + raw.get(i..j).map(str::to_string) +} + +impl Shape3d { + /// Reads kept 3-D XML; unknown parts take their defaults. + pub fn parse(raw: &str) -> Shape3d { + let mut s = Shape3d::default(); + let mut in_camera = false; + for (name, tag) in tags(raw) { + match name { + "camera" => { + s.camera = attr(tag, "prst").unwrap_or("orthographicFront").to_string(); + in_camera = !tag.ends_with('/'); + } + "rot" if in_camera => { + let d = |n| num(tag, n).unwrap_or(0.0) / ANGLE; + s.rot = Some((d("lon"), d("lat"), d("rev"))); + in_camera = false; + } + "lightRig" => { + in_camera = false; + s.light = attr(tag, "rig").unwrap_or("threePt").to_string(); + s.light_dir = attr(tag, "dir").unwrap_or("t").to_string(); + } + "sp3d" => { + in_camera = false; + s.depth = num(tag, "extrusionH").unwrap_or(0.0) / EMU; + s.contour = num(tag, "contourW").unwrap_or(0.0) / EMU; + s.z = num(tag, "z").unwrap_or(0.0) / EMU; + s.material = attr(tag, "prstMaterial").unwrap_or("warmMatte").to_string(); + } + "bevelT" | "bevelB" => { + let b = Bevel { + preset: attr(tag, "prst").unwrap_or("circle").to_string(), + w: num(tag, "w").unwrap_or(76200.0) / EMU, + h: num(tag, "h").unwrap_or(76200.0) / EMU, + }; + if name == "bevelT" { + s.top = Some(b); + } else { + s.bottom = Some(b); + } + } + _ => {} + } + } + s.colors = ["extrusionClr", "contourClr"].iter().filter_map(|n| element(raw, n)).collect(); + s + } + + /// Whether this is a flat shape with no 3-D at all. + pub fn is_flat(&self) -> bool { + self.camera == "orthographicFront" + && self.rot.is_none_or(|(x, y, z)| x == 0.0 && y == 0.0 && z == 0.0) + && self.top.is_none() + && self.bottom.is_none() + && self.depth == 0.0 + && self.contour == 0.0 + && self.z == 0.0 + } + + /// The `a:scene3d` and `a:sp3d` XML, or `None` for a flat shape. + pub fn to_xml(&self) -> Option { + if self.is_flat() { + return None; + } + let e = |pt: f64| (pt * EMU).round() as i64; + let mut x = format!(" { + let a = |d: f64| (d.rem_euclid(360.0) * ANGLE).round() as i64 % 21_600_000; + x += &format!(">", a(ry), a(rx), a(rz)); + } + None => x += "/>", + } + x += &format!(" 0.0 { + x += &format!(" extrusionH=\"{}\"", e(self.depth)); + } + if self.contour > 0.0 { + x += &format!(" contourW=\"{}\"", e(self.contour)); + } + if self.material != "warmMatte" { + x += &format!(" prstMaterial=\"{}\"", self.material); + } + x += ">"; + for (tag, b) in [("bevelT", &self.top), ("bevelB", &self.bottom)] { + if let Some(b) = b { + x += &format!("", e(b.w), e(b.h), b.preset); + } + } + x += &self.colors; + x += ""; + Some(x) + } + + /// Applies `{camera?, rotX?, rotY?, rotZ?, z?, bevelTop?, bevelTopW?, bevelTopH?, bevelBottom?, + /// bevelBottomW?, bevelBottomH?, depth?, contour?, material?, lighting?, lightAngle?}`. + pub fn apply(&mut self, p: &Value) { + let f = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + let pick = |k: &str, list: &[&str]| p.get(k).and_then(Value::as_str).filter(|v| list.contains(v)).map(str::to_string); + if let Some(c) = pick("camera", CAMERAS) { + self.camera = c; + // A preset sets its own angles. + self.rot = None; + } + let (rx, ry, rz) = (f("rotX"), f("rotY"), f("rotZ")); + if rx.is_some() || ry.is_some() || rz.is_some() { + let (ox, oy, oz) = self.rot.unwrap_or((0.0, 0.0, 0.0)); + let d = |v: Option, o: f64| v.map_or(o, |v| v.rem_euclid(360.0)); + self.rot = Some((d(rx, ox), d(ry, oy), d(rz, oz))); + } + if let Some(v) = f("z") { + self.z = v.clamp(-1584.0, 1584.0); + } + for (key, slot) in [("bevelTop", &mut self.top), ("bevelBottom", &mut self.bottom)] { + match p.get(key).and_then(Value::as_str) { + Some("none") => *slot = None, + Some(b) if BEVELS.contains(&b) => { + let old = slot.take().unwrap_or(Bevel { preset: String::new(), w: 6.0, h: 6.0 }); + *slot = Some(Bevel { preset: b.to_string(), ..old }); + } + _ => {} + } + if let Some(b) = slot.as_mut() { + if let Some(w) = f(&format!("{key}W")) { + b.w = w.clamp(0.0, 1584.0); + } + if let Some(h) = f(&format!("{key}H")) { + b.h = h.clamp(0.0, 1584.0); + } + } + } + if let Some(v) = f("depth") { + self.depth = v.clamp(0.0, 1584.0); + } + if let Some(v) = f("contour") { + self.contour = v.clamp(0.0, 1584.0); + } + if let Some(m) = pick("material", MATERIALS) { + self.material = m; + } + if let Some(l) = pick("lighting", LIGHTS) { + self.light = l; + } + } +} + +/// Sets a shape's 3-D settings on its effects, keeping `bevel` (the top bevel's preset) in step. +pub fn store(e: &mut deckcraft_model::Effects, s: &Shape3d) { + e.raw3d = s.to_xml(); + e.bevel = if e.raw3d.is_some() { s.top.as_ref().map(|b| b.preset.clone()) } else { None }; +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..ceb5dd1a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,53 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +#[test] +fn shape_three_d_format_and_rotation_round_trip() { + use crate::cmd::three_d::Shape3d; + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + let fx = |s: &Session| s.doc().unwrap().selected_shapes()[0].effects.clone().unwrap_or_default(); + // The old `bevel` key now writes real 3-D XML, so it is drawn and saved. + s.execute("shape.effects", &json!({"bevel": "angle"})).unwrap(); + let e = fx(&s); + assert_eq!(e.bevel.as_deref(), Some("angle")); + assert!(e.raw3d.as_deref().is_some_and(|r| r.contains("bevelT") && r.contains("prst=\"angle\""))); + s.execute( + "shape.effects", + &json!({"threeD": {"bevelTopW": 9, "bevelBottom": "circle", "depth": 20, "contour": 2, "material": "metal", + "lighting": "harsh", "camera": "isometricTopUp", "rotX": 30, "rotY": -20, "z": 5, "bogus": 1}}), + ) + .unwrap(); + // Unknown names and non-finite numbers are ignored. + s.execute("shape.effects", &json!({"threeD": {"material": "gold", "depth": f64::NAN, "camera": 7}})).unwrap(); + let want = fx(&s).raw3d.map(|r| Shape3d::parse(&r)).unwrap(); + assert_eq!(want.top.as_ref().map(|b| (b.preset.as_str(), b.w, b.h)), Some(("angle", 9.0, 6.0))); + assert_eq!(want.bottom.as_ref().map(|b| b.preset.as_str()), Some("circle")); + assert_eq!((want.depth, want.contour, want.z), (20.0, 2.0, 5.0)); + assert_eq!((want.material.as_str(), want.light.as_str(), want.camera.as_str()), ("metal", "harsh", "isometricTopUp")); + assert_eq!(want.rot, Some((30.0, 340.0, 0.0))); + // PPTX save and reopen keeps every setting. + let bytes = cmd::file::save_bytes(&s.doc().unwrap().doc, "pptx").unwrap(); + s.execute("file.openBytes", &json!({"name": "d.pptx", "data": cmd::base64_encode(&bytes)})).unwrap(); + let back = s.doc().unwrap().doc.slides.iter().flat_map(|sl| sl.shapes.iter()).find_map(|sh| sh.effects.as_ref()?.raw3d.clone()); + assert_eq!(Shape3d::parse(&back.unwrap()), want); +} + +#[test] +fn three_d_reset_and_flat_shapes_drop_the_xml() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap(); + let fx = |s: &Session| s.doc().unwrap().selected_shapes()[0].effects.clone().unwrap_or_default(); + s.execute("shape.effects", &json!({"threeD": {"rotZ": 15, "depth": 4}})).unwrap(); + assert!(fx(&s).raw3d.is_some()); + s.execute("shape.effects", &json!({"threeD": {"reset": true}})).unwrap(); + assert_eq!((fx(&s).raw3d, fx(&s).bevel), (None, None)); + // Keeps colours it doesn't model. + let raw = ""; + let mut s3 = cmd::three_d::Shape3d::parse(raw); + s3.apply(&json!({"depth": 3})); + assert!(s3.to_xml().unwrap().contains("")); +} diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 2b3ada3a..63c5e48b 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -150,6 +150,8 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { if ui.add(egui::Slider::new(&mut s, 0.0..=50.0).text("size (pt)")).drag_stopped() { run(app, "shape.effects", json!({"softEdges": s})); } + let raw3d = sh.effects.as_ref().and_then(|e| e.raw3d.clone()); + three_d_options(app, ui, sh.id.0, raw3d.as_deref()); if ui.button("Reset Effects").clicked() { run(app, "shape.effects", json!({"reset": true})); } @@ -339,6 +341,79 @@ fn format_shape(app: &mut SlideApp, ui: &mut Ui) { } } +/// "perspectiveHeroicLeftFacing" → "Perspective heroic left facing". +fn preset_label(name: &str) -> String { + let mut out = String::new(); + for (i, c) in name.chars().enumerate() { + if i == 0 { + out.extend(c.to_uppercase()); + } else if c.is_uppercase() { + out.push(' '); + out.extend(c.to_lowercase()); + } else { + out.push(c); + } + } + out +} + +/// A drop-down of `options`; picking one runs `shape.effects` with `{threeD: {key: option}}`. +fn three_d_combo(app: &mut SlideApp, ui: &mut Ui, id: u32, label: &str, key: &str, cur: &str, options: &[&str]) { + ui.horizontal(|ui| { + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + egui::ComboBox::from_id_salt(("3d", id, key)).selected_text(preset_label(cur)).width(170.0).show_ui(ui, |ui| { + for o in options { + if ui.selectable_label(*o == cur, preset_label(o)).clicked() { + run(app, "shape.effects", json!({"threeD": {key: o}})); + } + } + }); + }); +} + +/// A number field running `shape.effects` with `{threeD: {key: value}}` when it changes. +fn three_d_drag(app: &mut SlideApp, ui: &mut Ui, label: &str, value: f64, suffix: &str, key: &str) { + ui.horizontal(|ui| { + ui.add_sized(vec2(90.0, 20.0), egui::Label::new(label)); + let mut v = value; + let r = ui.add(egui::DragValue::new(&mut v).suffix(suffix).max_decimals(1)); + if r.drag_stopped() || r.lost_focus() || (r.changed() && !r.dragged()) { + run(app, "shape.effects", json!({"threeD": {key: v}})); + } + }); +} + +/// Format Shape ▸ Effects ▸ 3-D Format and 3-D Rotation. +fn three_d_options(app: &mut SlideApp, ui: &mut Ui, id: u32, raw: Option<&str>) { + use deckcraft_engine::cmd::three_d::{BEVELS, CAMERAS, LIGHTS, MATERIALS, Shape3d}; + let s3 = raw.map(Shape3d::parse).unwrap_or_default(); + let with_none: Vec<&str> = std::iter::once("none").chain(BEVELS.iter().copied()).collect(); + ui.add_space(6.0); + ui.label(egui::RichText::new("3-D Format").font(theme::bold(13.0))); + for (label, key, b) in [("Top bevel", "bevelTop", &s3.top), ("Bottom bevel", "bevelBottom", &s3.bottom)] { + three_d_combo(app, ui, id, label, key, b.as_ref().map_or("none", |b| b.preset.as_str()), &with_none); + if let Some(b) = b { + three_d_drag(app, ui, " Width", b.w, " pt", &format!("{key}W")); + three_d_drag(app, ui, " Height", b.h, " pt", &format!("{key}H")); + } + } + three_d_drag(app, ui, "Depth", s3.depth, " pt", "depth"); + three_d_drag(app, ui, "Contour", s3.contour, " pt", "contour"); + three_d_combo(app, ui, id, "Material", "material", &s3.material, MATERIALS); + three_d_combo(app, ui, id, "Lighting", "lighting", &s3.light, LIGHTS); + ui.add_space(6.0); + ui.label(egui::RichText::new("3-D Rotation").font(theme::bold(13.0))); + three_d_combo(app, ui, id, "Presets", "camera", &s3.camera, CAMERAS); + let (rx, ry, rz) = s3.rot.unwrap_or((0.0, 0.0, 0.0)); + three_d_drag(app, ui, "X Rotation", rx, "°", "rotX"); + three_d_drag(app, ui, "Y Rotation", ry, "°", "rotY"); + three_d_drag(app, ui, "Z Rotation", rz, "°", "rotZ"); + three_d_drag(app, ui, "Distance from ground", s3.z, " pt", "z"); + if ui.add_enabled(raw.is_some(), egui::Button::new("Reset 3-D")).clicked() { + run(app, "shape.effects", json!({"threeD": {"reset": true}})); + } +} + fn background(app: &mut SlideApp, ui: &mut Ui) { let sc = scheme(app); let bg = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.background.clone())); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c403f05e..709f6771 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -58,7 +58,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`). | Pane / dialog | PowerPoint | DeckCraft | |---|---|---| -| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, some effects, size; no 3-D, thin picture/texture options | +| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, some effects, 3-D format and rotation (bevels, depth, contour, material, lighting, camera presets, X/Y/Z rotation), size; thin picture/texture options | | Format Background pane | yes | yes | | Selection Pane | front-most first, groups fold, drag to restack, Bring Forward / Send Backward arrows, Show All / Hide All, eye, padlock (Lock), rename in place | same: drag restacks among siblings (a child stays in its group), Ctrl+click toggles, Shift+click selects a run, arrows / Home / End / F2 / Esc in the list, right-click menu | | Animation Pane | list + advanced timeline | list, reorder, timing; no timeline |