Merge PR #508 from NikitkaRa: Format Shape: 3-D Format and 3-D Rotation controls

# Conflicts:
#	crates/engine/src/cmd/shape.rs
#	crates/engine/src/tests.rs
#	crates/ui-egui/src/panes.rs
This commit is contained in:
Brandon Thomas
2026-10-14 15:40:11 -04:00
6 changed files with 445 additions and 3 deletions
+1
View File
@@ -27,6 +27,7 @@ pub mod slide;
pub mod smartart;
pub mod table;
pub mod text;
pub mod three_d;
pub mod transition;
use deckcraft_geom::Xfrm;
+14 -3
View File
@@ -95,7 +95,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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|…, shadowProps?: {color?, transparency?: 0..1, blur?: pt, dist?: pt, dir?: deg}, glowProps?: {color?, transparency?, radius?: pt}, reflectionProps?: {transparency?, size?: 0..1, dist?: pt, blur?: pt}, reset?, ids?} (the *Props keys adjust the current effect, adding a default one when there is none)",
"{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}, shadowProps?: {color?, transparency?: 0..1, blur?: pt, dist?: pt, dir?: deg}, glowProps?: {color?, transparency?, radius?: pt}, reflectionProps?: {transparency?, size?: 0..1, dist?: pt, blur?: pt}, reset?, ids?} (the *Props keys adjust the current effect, adding a default one when there is none)",
has_selection,
effects
),
@@ -772,6 +772,7 @@ fn effects(s: &mut Session, p: &Value) -> Result<Value> {
"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()) });
let shadow_props = p.get("shadowProps").filter(|v| v.is_object()).cloned();
let glow_props = p.get("glowProps").filter(|v| v.is_object()).cloned();
@@ -807,8 +808,18 @@ fn effects(s: &mut Session, p: &Value) -> Result<Value> {
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);
}
if let Some(v) = &shadow_props {
let slot = if e.inner_shadow.is_some() { &mut e.inner_shadow } else { &mut e.outer_shadow };
+304
View File
@@ -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<Bevel>,
pub bottom: Option<Bevel>,
/// 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<f64> {
attr(tag, name).and_then(|v| v.parse::<f64>().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<String> {
let open = format!("<a:{local}");
let close = format!("</a:{local}>");
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<String> {
if self.is_flat() {
return None;
}
let e = |pt: f64| (pt * EMU).round() as i64;
let mut x = format!("<a:scene3d><a:camera prst=\"{}\"", self.camera);
match self.rot {
Some((rx, ry, rz)) => {
let a = |d: f64| (d.rem_euclid(360.0) * ANGLE).round() as i64 % 21_600_000;
x += &format!("><a:rot lat=\"{}\" lon=\"{}\" rev=\"{}\"/></a:camera>", a(ry), a(rx), a(rz));
}
None => x += "/>",
}
x += &format!("<a:lightRig rig=\"{}\" dir=\"{}\"/></a:scene3d><a:sp3d", self.light, self.light_dir);
if self.z != 0.0 {
x += &format!(" z=\"{}\"", e(self.z));
}
if self.depth > 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!("<a:{tag} w=\"{}\" h=\"{}\" prst=\"{}\"/>", e(b.w), e(b.h), b.preset);
}
}
x += &self.colors;
x += "</a:sp3d>";
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<f64>, 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 };
}
+50
View File
@@ -2689,3 +2689,53 @@ fn ink_pressures(s: &Session) -> Vec<Vec<f32>> {
let deckcraft_model::ShapeKind::Ink { strokes } = &sh.kind else { return vec![] };
strokes.iter().map(|stroke| stroke.points.iter().map(|p| p.2).collect()).collect()
}
#[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 = "<a:scene3d><a:camera prst=\"orthographicFront\"/><a:lightRig rig=\"threePt\" dir=\"t\"/></a:scene3d><a:sp3d extrusionH=\"12700\"><a:extrusionClr><a:srgbClr val=\"FF0000\"/></a:extrusionClr></a:sp3d>";
let mut s3 = cmd::three_d::Shape3d::parse(raw);
s3.apply(&json!({"depth": 3}));
assert!(s3.to_xml().unwrap().contains("<a:extrusionClr><a:srgbClr val=\"FF0000\"/></a:extrusionClr>"));
}
+75
View File
@@ -320,6 +320,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}));
}
@@ -726,6 +728,79 @@ fn picture_fill_options(app: &mut SlideApp, ui: &mut Ui, id: u32, pf: &deckcraft
}
}
/// "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()));
+1
View File
@@ -98,6 +98,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, shadow/glow/reflection details (colour, transparency, blur, angle, distance, size), soft edges, size; no 3-D, thin picture/texture options |
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill (solid transparency follows the selection), 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, picture/texture fill (source, tile, offsets, scale, alignment, mirror, transparency, rotate with shape), line, some effects, size; no 3-D |
| 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 |
| Slide Master | Insert Layout, Rename, Delete, Preserve, Insert Placeholder, Title and Footers | Insert Layout, Rename, Delete, Insert Placeholder, and Preserve, Title and Footers checkboxes (`master.preserve`, `master.placeholders`). A master thumbnail selects the master |
| 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 |