mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
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:
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>"));
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user