mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 18:46:57 -06:00
1654 lines
68 KiB
Rust
1654 lines
68 KiB
Rust
//! Shape Format: position and size, rotation, flips, geometry, fill, outline, effects, styles,
|
||
//! names and alt text.
|
||
|
||
use std::collections::HashMap;
|
||
|
||
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
|
||
use deckcraft_model::style::{Dash, Glow, Gradient, GradientShape, GradientStop, LineEnd, PatternFill, PictureFill, PictureMode, Reflection, Shadow};
|
||
use deckcraft_model::{ColorRef, Effects, Fill, Geom, Line, ShapeStyle};
|
||
use serde_json::{Value, json};
|
||
|
||
use super::*;
|
||
use crate::{Result, Session};
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
cmd!(
|
||
"shape.connect",
|
||
"Connect Shapes",
|
||
[],
|
||
None,
|
||
"{from: shape id, to: shape id, fromSite?, toSite?: site index (default: the closest pair), id?: existing line to glue (else a new connector), preset?: straightConnector1|bentConnector3|curvedConnector3} → {id}",
|
||
has_slide,
|
||
connect
|
||
),
|
||
cmd!(
|
||
"shape.freeform",
|
||
"Freeform",
|
||
["Insert", "Shapes"],
|
||
None,
|
||
"{points: [[x, y], …] slide pt (≥ 2), closed?: bool (filled shape), smooth?: bool (curve through the points)} → {id}",
|
||
has_slide,
|
||
freeform
|
||
),
|
||
cmd!(
|
||
"draw.inkToShape",
|
||
"Ink to Shape",
|
||
["Draw", "Convert"],
|
||
None,
|
||
"{} → {shapes: [ids], kept: n} turns recognisable ink strokes on the slide into lines, rectangles, ellipses and triangles",
|
||
has_slide,
|
||
ink_to_shape
|
||
),
|
||
cmd!(query "shape.sites", "Connection Sites", [], None, "{id} → [[x, y]] connection sites in slide points", has_slide, sites),
|
||
cmd!(
|
||
"shape.move",
|
||
"Move",
|
||
[],
|
||
None,
|
||
"{dx?: pt (relative), dy?: pt (relative), ids?} | {x?: pt (absolute), y?: pt (absolute), ids?}",
|
||
has_selection,
|
||
move_by
|
||
),
|
||
cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?: pt, h?: pt, lockAspect?: bool, ids?}", has_selection, resize),
|
||
cmd!("shape.setBounds", "Position and Size", [], None, "{x, y, w, h, ids?}", has_selection, set_bounds),
|
||
cmd!(
|
||
"shape.rotate",
|
||
"Rotation",
|
||
["Shape Format", "Arrange", "Rotate"],
|
||
None,
|
||
"{deg?: absolute, by?: relative, ids?, gesture?: move|end}",
|
||
has_selection,
|
||
rotate
|
||
),
|
||
cmd!(
|
||
"shape.sizePosition",
|
||
"Size and Position…",
|
||
["Shape Format", "Size"],
|
||
None,
|
||
"{x?, y?: pt, w?, h?: pt, rot?: deg, scaleW?, scaleH?: percent of the current size (100 = unchanged), lockAspect?: bool, ids?} — with none of those keys, opens the dialog",
|
||
has_selection,
|
||
size_position
|
||
),
|
||
cmd!("shape.flip", "Flip", ["Shape Format", "Arrange", "Rotate"], None, "{axis: horizontal|vertical, ids?}", has_selection, flip),
|
||
cmd!("shape.adjust", "Adjust Shape", [], None, "{index, value (file units), ids?}", has_selection, adjust),
|
||
cmd!("shape.change", "Change Shape", ["Shape Format", "Insert Shapes", "Edit Shape"], None, "{preset, ids?}", has_selection, change),
|
||
cmd!(
|
||
"shape.smoothCorners",
|
||
"Smooth Corners",
|
||
["Shape Format", "Insert Shapes", "Edit Shape"],
|
||
None,
|
||
"{smoothing: 0..100 (%), radius?: pt (default: the rounded rectangle's own), ids?} → rectangle outline with smoothed (squircle) corners",
|
||
has_selection,
|
||
smooth_corners
|
||
),
|
||
cmd!(
|
||
"shape.fill",
|
||
"Shape Fill",
|
||
["Shape Format", "Shape Styles"],
|
||
None,
|
||
"{color? | none?: true | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
|
||
has_selection,
|
||
fill
|
||
),
|
||
cmd!(
|
||
"shape.line",
|
||
"Shape Outline",
|
||
["Shape Format", "Shape Styles"],
|
||
None,
|
||
"{color?, none?, width?: pt, dash?: solid|dot|dash|lgDash|dashDot|sysDot|sysDash, cap?: flat|round|square, join?: round|bevel|miter, compound?: single|double|thickThin|thinThick|triple, head?: none|triangle|stealth|diamond|oval|arrow, tail?, headSize?: sm|med|lg, tailSize?, reset?, ids?}; a group also formats its members",
|
||
has_selection,
|
||
line
|
||
),
|
||
cmd!(
|
||
"shape.effects",
|
||
"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|…, 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
|
||
),
|
||
cmd!(
|
||
"shape.quickStyle",
|
||
"Quick Styles",
|
||
["Home", "Drawing"],
|
||
None,
|
||
"{index: 0..41 (theme style row×accent), ids?}",
|
||
has_selection,
|
||
quick_style
|
||
),
|
||
cmd!("shape.rename", "Rename", ["Selection Pane"], None, "{name, id?}", has_selection, rename),
|
||
cmd!("shape.altText", "Alt Text", ["Shape Format", "Accessibility"], None, "{text, decorative?: bool, ids?}", has_selection, alt_text),
|
||
cmd!("shape.visible", "Show/Hide", ["Selection Pane"], None, "{visible?: bool, ids?}", has_selection, visible),
|
||
cmd!(
|
||
"shape.lock",
|
||
"Lock",
|
||
["Selection Pane"],
|
||
None,
|
||
"{locked?: bool (default: toggle), ids?} (a group locks or unlocks with everything in it)",
|
||
has_selection,
|
||
lock
|
||
),
|
||
cmd!("shape.setDefault", "Set as Default Shape", [], None, "{}", has_selection, set_default),
|
||
cmd!(
|
||
"picture.crop",
|
||
"Crop",
|
||
["Picture Format", "Size"],
|
||
None,
|
||
"{left?: fraction 0..1, top?: fraction 0..1, right?: fraction 0..1, bottom?: fraction 0..1, ids?}",
|
||
has_selection,
|
||
crop
|
||
),
|
||
cmd!(
|
||
"picture.adjust",
|
||
"Corrections",
|
||
["Picture Format", "Adjust"],
|
||
None,
|
||
"{brightness?: -1..1, contrast?: -1..1, saturation?: 0..4, grayscale?: bool, transparency?: 0..1, reset?: bool, ids?}",
|
||
has_selection,
|
||
picture_adjust
|
||
),
|
||
cmd!("picture.reset", "Reset Picture", ["Picture Format", "Adjust"], None, "{ids?}", has_selection, picture_reset),
|
||
cmd!(
|
||
"picture.change",
|
||
"Change Picture",
|
||
["Picture Format", "Adjust"],
|
||
None,
|
||
"{path? | data: base64, name?, ids?}",
|
||
has_selection,
|
||
picture_change
|
||
),
|
||
cmd!(
|
||
"media.options",
|
||
"Playback",
|
||
["Playback"],
|
||
None,
|
||
"{autoplay?, loop?, rewind?, acrossSlides?, hide?, fullScreen?, volume?: 0..1, trimStart?: ms, trimEnd?: ms, fadeIn?: ms, fadeOut?: ms, ids?}",
|
||
has_selection,
|
||
media_options
|
||
),
|
||
]
|
||
}
|
||
|
||
/// Keeps a side length in `0..=MAX_PT` so a huge value cannot exhaust memory when rendering.
|
||
/// An overflow to infinity lands on the limit; NaN becomes 0.
|
||
fn clamp_side(v: f64) -> f64 {
|
||
if v.is_nan() { 0.0 } else { v.clamp(0.0, MAX_PT) }
|
||
}
|
||
|
||
/// Keeps a coordinate within `±POS_LIMIT`.
|
||
fn clamp_pos(v: f64) -> f64 {
|
||
if v.is_nan() { 0.0 } else { v.clamp(-POS_LIMIT, POS_LIMIT) }
|
||
}
|
||
|
||
fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let (dx, dy) = (f64_param(p, "dx"), f64_param(p, "dy"));
|
||
let (ax, ay) = (f64_param(p, "x"), f64_param(p, "y"));
|
||
edit_geometry(s, p, "shape.move", |sh| {
|
||
if let Some(x) = sh.xfrm.as_mut() {
|
||
x.x = clamp_pos(ax.unwrap_or(x.x) + dx.unwrap_or(0.0));
|
||
x.y = clamp_pos(ay.unwrap_or(x.y) + dy.unwrap_or(0.0));
|
||
}
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
fn resize(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let (w, h) = (f64_param(p, "w").map(|v| v.max(0.0)), f64_param(p, "h").map(|v| v.max(0.0)));
|
||
let lock = bool_or(p, "lockAspect", false);
|
||
edit_geometry(s, p, "shape.resize", |sh| {
|
||
if let Some(x) = sh.xfrm.as_mut() {
|
||
let ar = if x.h > 0.0 { x.w / x.h } else { 1.0 };
|
||
let (cx, cy) = (x.x + x.w / 2.0, x.y + x.h / 2.0);
|
||
match (w, h, lock) {
|
||
(Some(w), _, true) => {
|
||
x.w = w;
|
||
x.h = if ar > 0.0 { w / ar } else { x.h };
|
||
}
|
||
(None, Some(h), true) => {
|
||
x.h = h;
|
||
x.w = h * ar;
|
||
}
|
||
_ => {
|
||
x.w = w.unwrap_or(x.w);
|
||
x.h = h.unwrap_or(x.h);
|
||
}
|
||
}
|
||
x.w = clamp_side(x.w);
|
||
x.h = clamp_side(x.h);
|
||
// Rotated shapes resize about their centre; others keep the top-left.
|
||
if x.rot != 0.0 {
|
||
x.x = clamp_pos(cx - x.w / 2.0);
|
||
x.y = clamp_pos(cy - x.h / 2.0);
|
||
}
|
||
}
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||
edit_geometry(s, p, "shape.setBounds", |sh| {
|
||
if let Some(x) = sh.xfrm.as_mut() {
|
||
x.x = clamp_pos(f64_param(p, "x").unwrap_or(x.x));
|
||
x.y = clamp_pos(f64_param(p, "y").unwrap_or(x.y));
|
||
x.w = clamp_side(f64_param(p, "w").unwrap_or(x.w));
|
||
x.h = clamp_side(f64_param(p, "h").unwrap_or(x.h));
|
||
}
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
const SIZE_POS: &str = "shape.sizePosition";
|
||
/// Smallest and largest side the dialog will write, in points (220 in).
|
||
const MIN_PT: f64 = 0.01;
|
||
const MAX_PT: f64 = 15_840.0;
|
||
const POS_LIMIT: f64 = 100_000.0;
|
||
const MAX_SCALE: f64 = 10_000.0;
|
||
|
||
#[derive(Clone, Copy)]
|
||
struct SizeSpec {
|
||
x: Option<f64>,
|
||
y: Option<f64>,
|
||
w: Option<f64>,
|
||
h: Option<f64>,
|
||
rot: Option<f64>,
|
||
scale_w: Option<f64>,
|
||
scale_h: Option<f64>,
|
||
lock: bool,
|
||
}
|
||
|
||
impl SizeSpec {
|
||
fn width_set(self) -> bool {
|
||
self.w.is_some() || self.scale_w.is_some()
|
||
}
|
||
fn height_set(self) -> bool {
|
||
self.h.is_some() || self.scale_h.is_some()
|
||
}
|
||
fn any(self) -> bool {
|
||
self.x.is_some() || self.y.is_some() || self.width_set() || self.height_set() || self.rot.is_some()
|
||
}
|
||
}
|
||
|
||
fn finite_key(p: &Value, key: &str) -> Result<Option<f64>> {
|
||
match p.get(key) {
|
||
None | Some(Value::Null) => Ok(None),
|
||
Some(v) => match v.as_f64() {
|
||
Some(n) if n.is_finite() => Ok(Some(n)),
|
||
_ => Err(bad(SIZE_POS, format!("`{key}` must be a finite number"))),
|
||
},
|
||
}
|
||
}
|
||
|
||
fn in_range(v: Option<f64>, key: &str, lo: f64, hi: f64) -> Result<Option<f64>> {
|
||
if let Some(n) = v
|
||
&& !(lo..=hi).contains(&n)
|
||
{
|
||
return Err(bad(SIZE_POS, format!("`{key}` is out of range")));
|
||
}
|
||
Ok(v)
|
||
}
|
||
|
||
fn size_spec(p: &Value) -> Result<SizeSpec> {
|
||
let lock = match p.get("lockAspect") {
|
||
None | Some(Value::Null) => false,
|
||
Some(Value::Bool(b)) => *b,
|
||
Some(_) => return Err(bad(SIZE_POS, "`lockAspect` must be true or false")),
|
||
};
|
||
Ok(SizeSpec {
|
||
x: in_range(finite_key(p, "x")?, "x", -POS_LIMIT, POS_LIMIT)?,
|
||
y: in_range(finite_key(p, "y")?, "y", -POS_LIMIT, POS_LIMIT)?,
|
||
w: in_range(finite_key(p, "w")?, "w", MIN_PT, MAX_PT)?,
|
||
h: in_range(finite_key(p, "h")?, "h", MIN_PT, MAX_PT)?,
|
||
rot: finite_key(p, "rot")?,
|
||
scale_w: in_range(finite_key(p, "scaleW")?, "scaleW", f64::MIN_POSITIVE, MAX_SCALE)?,
|
||
scale_h: in_range(finite_key(p, "scaleH")?, "scaleH", f64::MIN_POSITIVE, MAX_SCALE)?,
|
||
lock,
|
||
})
|
||
}
|
||
|
||
fn norm_deg(r: f64) -> f64 {
|
||
let r = r % 360.0;
|
||
if r < 0.0 { r + 360.0 } else { r }
|
||
}
|
||
|
||
/// Width wins when both sides are set with the aspect locked, matching `shape.resize`.
|
||
fn apply_size(x: &mut deckcraft_geom::Xfrm, spec: SizeSpec) {
|
||
let ar = if x.h > 0.0 { x.w / x.h } else { 1.0 };
|
||
let mut nw = x.w;
|
||
let mut nh = x.h;
|
||
if let Some(p) = spec.scale_w {
|
||
nw = x.w * p / 100.0;
|
||
}
|
||
if let Some(p) = spec.scale_h {
|
||
nh = x.h * p / 100.0;
|
||
}
|
||
if let Some(w) = spec.w {
|
||
nw = w;
|
||
}
|
||
if let Some(h) = spec.h {
|
||
nh = h;
|
||
}
|
||
if spec.lock && (spec.width_set() || spec.height_set()) {
|
||
if spec.width_set() {
|
||
nh = if ar > 0.0 { nw / ar } else { nh };
|
||
} else {
|
||
nw = nh * ar;
|
||
}
|
||
}
|
||
x.w = nw.clamp(MIN_PT, MAX_PT);
|
||
x.h = nh.clamp(MIN_PT, MAX_PT);
|
||
if let Some(px) = spec.x {
|
||
x.x = px;
|
||
}
|
||
if let Some(py) = spec.y {
|
||
x.y = py;
|
||
}
|
||
if let Some(r) = spec.rot {
|
||
x.rot = norm_deg(r);
|
||
}
|
||
}
|
||
|
||
fn size_position(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let spec = size_spec(p)?;
|
||
if !spec.any() {
|
||
let (x, y, w, h, rot) = {
|
||
let st = s.doc()?;
|
||
let id = targets(s, p)?.first().copied().ok_or_else(|| bad(SIZE_POS, "nothing selected"))?;
|
||
let sh = st.shape(id).ok_or_else(|| bad(SIZE_POS, "no such shape"))?;
|
||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||
(x.x, x.y, x.w, x.h, x.rot)
|
||
};
|
||
s.ui_requests.push(crate::UiRequest::Dialog {
|
||
id: "sizePosition".into(),
|
||
params: json!({"x": x, "y": y, "w": w, "h": h, "rot": rot, "scaleW": 100.0, "scaleH": 100.0, "lockAspect": false}),
|
||
});
|
||
return Ok(Value::Null);
|
||
}
|
||
let changed = {
|
||
let ids = targets(s, p)?;
|
||
let st = s.doc()?;
|
||
let free: Vec<_> = ids.iter().copied().filter(|id| !st.shape(*id).is_some_and(|sh| sh.locked)).collect::<Vec<_>>();
|
||
if !ids.is_empty() && free.is_empty() {
|
||
return Err(bad(SIZE_POS, "the object is locked"));
|
||
}
|
||
free.iter().any(|id| {
|
||
st.shape(*id).is_some_and(|sh| {
|
||
let mut x = xfrm_of(&st.doc, &st.selection, sh);
|
||
let before = x;
|
||
apply_size(&mut x, spec);
|
||
x != before
|
||
})
|
||
})
|
||
};
|
||
if !changed {
|
||
return Ok(Value::Null);
|
||
}
|
||
edit_geometry(s, p, SIZE_POS, |sh| {
|
||
if let Some(x) = sh.xfrm.as_mut() {
|
||
apply_size(x, spec);
|
||
}
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||
// A trackpad twist sends many small steps. `gesture: move` keeps them as one undo;
|
||
// `gesture: end` commits that step and does not rotate again.
|
||
if p.get("gesture").and_then(Value::as_str) == Some("end") {
|
||
s.commit_named("Rotation");
|
||
return Ok(json!({}));
|
||
}
|
||
if p.get("gesture").and_then(Value::as_str) == Some("move") {
|
||
s.begin("Rotation");
|
||
}
|
||
let (abs, by) = (f64_param(p, "deg"), f64_param(p, "by"));
|
||
edit_geometry(s, p, "shape.rotate", |sh| {
|
||
if let Some(x) = sh.xfrm.as_mut() {
|
||
let r = abs.unwrap_or(x.rot) + by.unwrap_or(0.0);
|
||
x.rot = ((r % 360.0) + 360.0) % 360.0;
|
||
}
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
fn flip(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let v = str_param(p, "axis").unwrap_or("horizontal").starts_with('v');
|
||
edit_geometry(s, p, "shape.flip", |sh| {
|
||
if let Some(x) = sh.xfrm.as_mut() {
|
||
if v {
|
||
x.flip_v = !x.flip_v;
|
||
} else {
|
||
x.flip_h = !x.flip_h;
|
||
}
|
||
// `Xfrm::affine` flips first and rotates second; a true mirror also negates the rotation.
|
||
x.rot = (360.0 - x.rot_norm()) % 360.0;
|
||
}
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let i = usize_param(p, "index").unwrap_or(0).min(16);
|
||
let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?;
|
||
edit_geometry(s, p, "shape.adjust", |sh| {
|
||
if let Geom::Preset { name, adj } = &mut sh.geom {
|
||
let defaults = deckcraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]);
|
||
while adj.len() <= i {
|
||
let k = adj.len();
|
||
adj.push(defaults.get(k).copied().unwrap_or(0.0));
|
||
}
|
||
if let Some(slot) = adj.get_mut(i) {
|
||
*slot = v;
|
||
}
|
||
}
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
/// Rectangles and rounded rectangles become custom geometry whose corners are smoothed
|
||
/// ([`deckcraft_geom::squircle`]); the radius is the rounded rectangle's own unless `radius` says.
|
||
fn smooth_corners(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "shape.smoothCorners";
|
||
let smoothing =
|
||
f64_param(p, "smoothing").filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "missing `smoothing` (0–100)"))?.clamp(0.0, 100.0) / 100.0;
|
||
let radius = f64_param(p, "radius").filter(|v| v.is_finite()).map(|v| v.max(0.0));
|
||
edit_geometry(s, p, CMD, |sh| {
|
||
let Some(x) = sh.xfrm else { return Err(bad(CMD, "the shape has no size of its own")) };
|
||
let ss = x.w.min(x.h).max(0.0);
|
||
let own = match &sh.geom {
|
||
Geom::Preset { name, adj } if name == "roundRect" => Some(ss * adj.first().copied().unwrap_or(16667.0).clamp(0.0, 50000.0) / 100000.0),
|
||
Geom::Preset { name, .. } if name == "rect" => Some(ss * 16667.0 / 100000.0),
|
||
_ => None,
|
||
};
|
||
// A custom outline qualifies only when it is a squircle this command drew with `radius`
|
||
// (re-smoothing as the slider moves), never an arbitrary freeform.
|
||
let custom = match (&sh.geom, radius) {
|
||
(Geom::Custom { paths }, Some(r)) => match paths.as_slice() {
|
||
[only] => (0..=100).any(|k| deckcraft_geom::squircle::path_d(only.w, only.h, r, f64::from(k) / 100.0) == only.d),
|
||
_ => false,
|
||
},
|
||
_ => false,
|
||
};
|
||
let r = match (radius, own) {
|
||
(Some(r), _) if own.is_some() || custom => r,
|
||
(None, Some(r)) => r,
|
||
_ => return Err(bad(CMD, "only rectangles, rounded rectangles and shapes already smoothed with this `radius` can be smoothed")),
|
||
};
|
||
sh.geom = Geom::Custom {
|
||
paths: vec![deckcraft_model::CustomPath {
|
||
w: x.w,
|
||
h: x.h,
|
||
d: deckcraft_geom::squircle::path_d(x.w, x.h, r, smoothing),
|
||
..Default::default()
|
||
}],
|
||
};
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let name = str_param(p, "preset").ok_or_else(|| bad("shape.change", "missing `preset`"))?;
|
||
let info = deckcraft_geom::preset::info(name).ok_or_else(|| bad("shape.change", format!("unknown preset `{name}`")))?;
|
||
edit_geometry(s, p, "shape.change", |sh| {
|
||
sh.geom = Geom::preset(info.name);
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
/// `{stops: [[pos 0–1, color], …], kind?: linear|radial|rectangular|path, angle?: deg (linear),
|
||
/// focus?: [l, t, r, b] fractions (radial/rectangular/path: where the first stop sits),
|
||
/// rotateWithShape?}`.
|
||
pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
|
||
let stops: Vec<GradientStop> = v
|
||
.get("stops")?
|
||
.as_array()?
|
||
.iter()
|
||
.filter_map(|s| {
|
||
let a = s.as_array()?;
|
||
Some(GradientStop { pos: a.first()?.as_f64()?.clamp(0.0, 1.0), color: color_value(a.get(1)?)? })
|
||
})
|
||
.collect();
|
||
if stops.len() < 2 {
|
||
return None;
|
||
}
|
||
let focus = v
|
||
.get("focus")
|
||
.and_then(Value::as_array)
|
||
.and_then(|a| {
|
||
let f = |i: usize| a.get(i).and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0));
|
||
Some([f(0)?, f(1)?, f(2)?, f(3)?])
|
||
})
|
||
.unwrap_or([0.5, 0.5, 0.5, 0.5]);
|
||
let mut stops = stops;
|
||
stops.sort_by(|a, b| a.pos.total_cmp(&b.pos));
|
||
let path = |p: &str| GradientShape::Path { path: p.into(), focus };
|
||
let shape = match v.get("kind").and_then(Value::as_str) {
|
||
Some("radial" | "circle") => path("circle"),
|
||
Some("rectangular" | "rect") => path("rect"),
|
||
Some("path" | "shape") => path("shape"),
|
||
_ => GradientShape::Linear {
|
||
angle: v.get("angle").and_then(Value::as_f64).filter(|a| a.is_finite()).unwrap_or(90.0).rem_euclid(360.0),
|
||
scaled: false,
|
||
},
|
||
};
|
||
Some(Gradient { tile: [0.0; 4], stops, shape, rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true) })
|
||
}
|
||
|
||
/// Fill and outline apply to a group's visible members as well as its container. Other edits
|
||
/// (notably geometry) still target only the selected shape. The whole traversal stays in one edit.
|
||
fn edit_group_appearance(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<Value> {
|
||
fn apply(sh: &mut deckcraft_model::Shape, depth: usize, f: &dyn Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<()> {
|
||
if depth > 64 {
|
||
return Ok(());
|
||
}
|
||
f(sh)?;
|
||
if let Some(children) = sh.children_mut() {
|
||
for child in children {
|
||
apply(child, depth + 1, f)?;
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
edit_shapes(s, p, cmd, |sh| apply(sh, 0, &f))
|
||
}
|
||
|
||
fn fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let new_fill: Option<Option<Fill>> = if bool_or(p, "reset", false) {
|
||
Some(None)
|
||
} else if bool_or(p, "none", false) {
|
||
Some(Some(Fill::None))
|
||
} else if let Some(c) = color_param(p, "color") {
|
||
Some(Some(Fill::solid(c)))
|
||
} else if let Some(g) = p.get("gradient") {
|
||
Some(Some(Fill::Gradient(
|
||
gradient_param(g).ok_or_else(|| bad("shape.fill", "gradient needs at least two `stops`: [[0, \"#fff\"], [1, \"#000\"]]"))?,
|
||
)))
|
||
} else if let Some(pt) = p.get("pattern") {
|
||
Some(Some(Fill::Pattern(PatternFill {
|
||
preset: pt.get("preset").and_then(Value::as_str).unwrap_or("pct50").to_string(),
|
||
fg: pt.get("fg").and_then(color_value).unwrap_or(ColorRef::scheme(SchemeSlot::Accent1)),
|
||
bg: pt.get("bg").and_then(color_value).unwrap_or(ColorRef::scheme(SchemeSlot::Bg1)),
|
||
})))
|
||
} else if p.get("picture").is_some() {
|
||
let (name, bytes) = super::insert::media_bytes(&with_param(p, "data", p.get("picture").cloned().unwrap_or_default()), "shape.fill")?;
|
||
let ct = super::insert::content_type(&name, &bytes);
|
||
let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("shape.fill", NO_ID_SPACE)))?;
|
||
Some(Some(Fill::Picture(PictureFill { media, ..Default::default() })))
|
||
} else {
|
||
None
|
||
};
|
||
let transparency = f64_param(p, "transparency");
|
||
let pic_props = p.get("pictureProps").filter(|v| v.is_object());
|
||
if new_fill.is_none() && transparency.is_none() && pic_props.is_none() {
|
||
return Err(bad("shape.fill", "give `color`, `none`, `gradient`, `pattern`, `picture`, `pictureProps`, `transparency` or `reset`"));
|
||
}
|
||
// Transparency on an inherited (style) fill needs the effective colour.
|
||
let st = s.doc()?;
|
||
let ctx_fill = {
|
||
let ctx = super::text::ctx_for(&st.doc, &st.selection);
|
||
let mut fills = HashMap::new();
|
||
for id in targets(s, p)? {
|
||
if let Some(sh) = st.shape(id) {
|
||
deckcraft_model::walk(std::slice::from_ref(sh), &mut |sh, _| {
|
||
let fill = ctx.as_ref().and_then(|c| {
|
||
let (f, ph) = deckcraft_model::resolve::fill(c, sh);
|
||
f.map(|f| match f {
|
||
Fill::Solid { color } => Fill::solid(ColorRef::rgb(c.color(&color, ph))),
|
||
other => other,
|
||
})
|
||
});
|
||
fills.insert(sh.id, fill);
|
||
});
|
||
}
|
||
}
|
||
fills
|
||
};
|
||
edit_group_appearance(s, p, "shape.fill", |sh| {
|
||
if let Some(f) = &new_fill {
|
||
sh.fill.clone_from(f);
|
||
}
|
||
if let (Some(pp), Some(Fill::Picture(pf))) = (pic_props, sh.fill.as_mut()) {
|
||
picture_props(pf, pp);
|
||
}
|
||
if let (Some(t), Some(Fill::Picture(pf))) = (transparency, sh.fill.as_mut()) {
|
||
let a = 1.0 - t.clamp(0.0, 1.0);
|
||
pf.alpha = (a < 1.0).then_some(a);
|
||
return Ok(());
|
||
}
|
||
if let Some(t) = transparency {
|
||
let base = sh.fill.clone().or_else(|| ctx_fill.get(&sh.id).cloned().flatten());
|
||
if let Some(Fill::Solid { mut color }) = base {
|
||
color.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_)));
|
||
if t > 0.0 {
|
||
color.mods.push(ColorTransform::Alpha(((1.0 - t.clamp(0.0, 1.0)) * 100_000.0).round() as i32));
|
||
}
|
||
sh.fill = Some(Fill::Solid { color });
|
||
}
|
||
}
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
/// Applies Format Shape ▸ Picture or texture fill options to a picture fill.
|
||
fn picture_props(pf: &mut PictureFill, pp: &Value) {
|
||
let f = |k: &str| pp.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
|
||
if let Some(up) = pp.get("rotateWithShape").and_then(Value::as_bool) {
|
||
pf.upright = !up;
|
||
}
|
||
match pp.get("tile").and_then(Value::as_bool) {
|
||
Some(true) if !matches!(pf.mode, PictureMode::Tile { .. }) => {
|
||
pf.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() };
|
||
}
|
||
Some(false) if !matches!(pf.mode, PictureMode::Stretch { .. }) => pf.mode = PictureMode::default(),
|
||
_ => {}
|
||
}
|
||
match &mut pf.mode {
|
||
PictureMode::Tile { tx, ty, sx, sy, flip, align } => {
|
||
if let Some(v) = f("offsetX") {
|
||
*tx = v.clamp(-4000.0, 4000.0);
|
||
}
|
||
if let Some(v) = f("offsetY") {
|
||
*ty = v.clamp(-4000.0, 4000.0);
|
||
}
|
||
if let Some(v) = f("scaleX") {
|
||
*sx = v.clamp(0.01, 10.0);
|
||
}
|
||
if let Some(v) = f("scaleY") {
|
||
*sy = v.clamp(0.01, 10.0);
|
||
}
|
||
if let Some(m) = pp.get("mirror").and_then(Value::as_str).filter(|m| matches!(*m, "none" | "x" | "y" | "xy")) {
|
||
*flip = m.to_string();
|
||
}
|
||
if let Some(a) =
|
||
pp.get("align").and_then(Value::as_str).filter(|a| matches!(*a, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br"))
|
||
{
|
||
*align = a.to_string();
|
||
}
|
||
}
|
||
PictureMode::Stretch { fill_rect } => {
|
||
if let Some(arr) = pp.get("stretch").and_then(Value::as_array) {
|
||
for (slot, v) in fill_rect.iter_mut().zip(arr) {
|
||
if let Some(v) = v.as_f64().filter(|v| v.is_finite()) {
|
||
*slot = v.clamp(-10.0, 0.99);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn line_end(v: &str) -> Option<LineEnd> {
|
||
if v == "none" { None } else { Some(LineEnd { kind: v.to_string(), w: "med".into(), len: "med".into() }) }
|
||
}
|
||
|
||
fn line(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let reset = bool_or(p, "reset", false);
|
||
let none = bool_or(p, "none", false);
|
||
let color = color_param(p, "color");
|
||
let width = f64_param(p, "width").map(|w| w.clamp(0.0, 1584.0));
|
||
let dash = str_param(p, "dash").map(|d| Dash::Preset { name: d.to_string() });
|
||
let head = str_param(p, "head").map(line_end);
|
||
let tail = str_param(p, "tail").map(line_end);
|
||
let cap = str_param(p, "cap").map(|c| match c {
|
||
"round" => deckcraft_model::style::LineCap::Round,
|
||
"square" => deckcraft_model::style::LineCap::Square,
|
||
_ => deckcraft_model::style::LineCap::Flat,
|
||
});
|
||
let compound = str_param(p, "compound").map(|c| match c {
|
||
"double" => deckcraft_model::style::Compound::Double,
|
||
"thickThin" => deckcraft_model::style::Compound::ThickThin,
|
||
"thinThick" => deckcraft_model::style::Compound::ThinThick,
|
||
"triple" => deckcraft_model::style::Compound::Triple,
|
||
_ => deckcraft_model::style::Compound::Single,
|
||
});
|
||
let end_size = |k: &str| str_param(p, k).filter(|v| matches!(*v, "sm" | "med" | "lg")).map(str::to_string);
|
||
let (head_size, tail_size) = (end_size("headSize"), end_size("tailSize"));
|
||
let join = str_param(p, "join").map(|c| match c {
|
||
"bevel" => deckcraft_model::style::LineJoin::Bevel,
|
||
"miter" => deckcraft_model::style::LineJoin::Miter,
|
||
_ => deckcraft_model::style::LineJoin::Round,
|
||
});
|
||
edit_group_appearance(s, p, "shape.line", |sh| {
|
||
if reset {
|
||
sh.line = None;
|
||
return Ok(());
|
||
}
|
||
let l = sh.line.get_or_insert_with(Line::default);
|
||
if none {
|
||
l.fill = Some(Fill::None);
|
||
}
|
||
if let Some(c) = &color {
|
||
l.fill = Some(Fill::solid(c.clone()));
|
||
}
|
||
if width.is_some() {
|
||
l.width = width;
|
||
if l.fill.is_none() || matches!(l.fill, Some(Fill::None)) && !none {
|
||
l.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1)));
|
||
}
|
||
}
|
||
if dash.is_some() {
|
||
l.dash.clone_from(&dash);
|
||
}
|
||
let keep_size = |old: &Option<LineEnd>, new: &Option<LineEnd>| {
|
||
new.clone().map(|mut n| {
|
||
if let Some(o) = old {
|
||
n.w.clone_from(&o.w);
|
||
n.len.clone_from(&o.len);
|
||
}
|
||
n
|
||
})
|
||
};
|
||
if let Some(h) = &head {
|
||
l.head = keep_size(&l.head, h);
|
||
}
|
||
if let Some(t) = &tail {
|
||
l.tail = keep_size(&l.tail, t);
|
||
}
|
||
if cap.is_some() {
|
||
l.cap = cap;
|
||
}
|
||
if join.is_some() {
|
||
l.join = join;
|
||
}
|
||
if compound.is_some() {
|
||
l.compound = compound;
|
||
}
|
||
// Arrowhead size (width and length together), kept when the kind changes.
|
||
for (end, size) in [(&mut l.head, &head_size), (&mut l.tail, &tail_size)] {
|
||
if let (Some(e), Some(sz)) = (end.as_mut(), size) {
|
||
e.w.clone_from(sz);
|
||
e.len.clone_from(sz);
|
||
}
|
||
}
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
fn shadow_preset(v: &Value) -> Option<Option<Shadow>> {
|
||
let black = |a: i32| ColorRef::rgb(Rgba::BLACK).with(ColorTransform::Alpha(a));
|
||
let base = |inner: bool| Shadow {
|
||
color: black(40000),
|
||
blur: 4.0,
|
||
dist: 3.0,
|
||
dir: 45.0,
|
||
inner,
|
||
sx: 1.0,
|
||
sy: 1.0,
|
||
kx: 0.0,
|
||
ky: 0.0,
|
||
align: "tl".into(),
|
||
rotate_with_shape: false,
|
||
};
|
||
match v {
|
||
Value::String(s) => Some(match s.as_str() {
|
||
"none" => None,
|
||
"inner" => Some(base(true)),
|
||
"perspective" => Some(Shadow { sy: 0.23, kx: -1200000.0 / 60000.0, dir: 90.0, dist: 0.0, blur: 6.0, align: "bl".into(), ..base(false) }),
|
||
"bottom" => Some(Shadow { dir: 90.0, ..base(false) }),
|
||
"center" => Some(Shadow { dist: 0.0, blur: 8.0, ..base(false) }),
|
||
_ => Some(base(false)),
|
||
}),
|
||
Value::Object(o) => Some(Some(Shadow {
|
||
color: o.get("color").and_then(color_value).unwrap_or(black(40000)),
|
||
blur: o.get("blur").and_then(Value::as_f64).unwrap_or(4.0).clamp(0.0, 200.0),
|
||
dist: o.get("dist").and_then(Value::as_f64).unwrap_or(3.0).clamp(0.0, 200.0),
|
||
dir: o.get("dir").and_then(Value::as_f64).unwrap_or(45.0),
|
||
inner: o.get("inner").and_then(Value::as_bool).unwrap_or(false),
|
||
..base(false)
|
||
})),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn effects(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let reset = bool_or(p, "reset", false);
|
||
let shadow = p.get("shadow").and_then(shadow_preset);
|
||
let glow: Option<Option<Glow>> = p.get("glow").map(|g| match g {
|
||
Value::Object(o) => Some(Glow {
|
||
color: o.get("color").and_then(color_value).unwrap_or(ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(40000))),
|
||
radius: o.get("radius").and_then(Value::as_f64).unwrap_or(8.0).clamp(0.0, 150.0),
|
||
}),
|
||
Value::Number(n) => Some(Glow {
|
||
color: ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(40000)),
|
||
radius: n.as_f64().unwrap_or(8.0).clamp(0.0, 150.0),
|
||
}),
|
||
_ => None,
|
||
});
|
||
let soft = p.get("softEdges").map(|v| v.as_f64().filter(|x| *x > 0.0).map(|x| x.min(100.0)));
|
||
let refl = str_param(p, "reflection").map(|r| match r {
|
||
"none" => None,
|
||
"half" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.55, dist: 4.0 }),
|
||
"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();
|
||
let refl_props = p.get("reflectionProps").filter(|v| v.is_object()).cloned();
|
||
edit_shapes(s, p, "shape.effects", |sh| {
|
||
if reset {
|
||
sh.effects = None;
|
||
return Ok(());
|
||
}
|
||
let e = sh.effects.get_or_insert_with(Effects::default);
|
||
if let Some(sw) = &shadow {
|
||
match sw {
|
||
Some(x) if x.inner => {
|
||
e.inner_shadow = Some(x.clone());
|
||
e.outer_shadow = None;
|
||
}
|
||
Some(x) => {
|
||
e.outer_shadow = Some(x.clone());
|
||
e.inner_shadow = None;
|
||
}
|
||
None => {
|
||
e.outer_shadow = None;
|
||
e.inner_shadow = None;
|
||
}
|
||
}
|
||
}
|
||
if let Some(g) = &glow {
|
||
e.glow.clone_from(g);
|
||
}
|
||
if let Some(sf) = soft {
|
||
e.soft_edge = sf;
|
||
}
|
||
if let Some(r) = &refl {
|
||
e.reflection.clone_from(r);
|
||
}
|
||
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 };
|
||
let mut x = slot.take().or_else(|| shadow_preset(&json!("outer")).flatten()).ok_or_else(|| bad("shape.effects", "no shadow preset"))?;
|
||
if let Some(c) = v.get("color").and_then(color_value) {
|
||
let a = x.color.alpha();
|
||
x.color = with_alpha(c, a);
|
||
}
|
||
if let Some(t) = num(v, "transparency") {
|
||
x.color = with_alpha(x.color.clone(), 1.0 - t.clamp(0.0, 1.0));
|
||
}
|
||
if let Some(b) = num(v, "blur") {
|
||
x.blur = b.clamp(0.0, 100.0);
|
||
}
|
||
if let Some(d) = num(v, "dist") {
|
||
x.dist = d.clamp(0.0, 200.0);
|
||
}
|
||
if let Some(d) = num(v, "dir") {
|
||
x.dir = d.rem_euclid(360.0);
|
||
}
|
||
*slot = Some(x);
|
||
}
|
||
if let Some(v) = &glow_props {
|
||
let mut g =
|
||
e.glow.take().unwrap_or(Glow { color: ColorRef::scheme(SchemeSlot::Accent1).with(ColorTransform::Alpha(40000)), radius: 8.0 });
|
||
if let Some(c) = v.get("color").and_then(color_value) {
|
||
let a = g.color.alpha();
|
||
g.color = with_alpha(c, a);
|
||
}
|
||
if let Some(t) = num(v, "transparency") {
|
||
g.color = with_alpha(g.color.clone(), 1.0 - t.clamp(0.0, 1.0));
|
||
}
|
||
if let Some(r) = num(v, "radius") {
|
||
g.radius = r.clamp(0.0, 150.0);
|
||
}
|
||
e.glow = (g.radius > 0.0).then_some(g);
|
||
}
|
||
if let Some(v) = &refl_props {
|
||
let mut r = e.reflection.take().unwrap_or(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 });
|
||
if let Some(t) = num(v, "transparency") {
|
||
r.start_alpha = 1.0 - t.clamp(0.0, 1.0);
|
||
}
|
||
if let Some(sz) = num(v, "size") {
|
||
r.end_pos = sz.clamp(0.0, 1.0);
|
||
}
|
||
if let Some(d) = num(v, "dist") {
|
||
r.dist = d.clamp(0.0, 100.0);
|
||
}
|
||
if let Some(b) = num(v, "blur") {
|
||
r.blur = b.clamp(0.0, 100.0);
|
||
}
|
||
e.reflection = Some(r);
|
||
}
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
/// A finite number at `key` of an effect's properties.
|
||
fn num(v: &Value, key: &str) -> Option<f64> {
|
||
v.get(key).and_then(Value::as_f64).filter(|x| x.is_finite())
|
||
}
|
||
|
||
/// `c` with its alpha set to `a` (0–1); fully opaque drops the transform.
|
||
fn with_alpha(mut c: ColorRef, a: f64) -> ColorRef {
|
||
c.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_)));
|
||
if a < 1.0 {
|
||
c.mods.push(ColorTransform::Alpha((a.clamp(0.0, 1.0) * 100_000.0).round() as i32));
|
||
}
|
||
c
|
||
}
|
||
|
||
/// Theme quick styles: 7 rows (outline only, solid fill variants, intense…) × 6 accents.
|
||
pub fn quick_style_for(index: usize) -> (ShapeStyle, Option<Fill>, Option<Line>) {
|
||
let slots = [
|
||
SchemeSlot::Dk1,
|
||
SchemeSlot::Accent1,
|
||
SchemeSlot::Accent2,
|
||
SchemeSlot::Accent3,
|
||
SchemeSlot::Accent4,
|
||
SchemeSlot::Accent5,
|
||
SchemeSlot::Accent6,
|
||
];
|
||
let slot = slots.get(index % 7).copied().unwrap_or(SchemeSlot::Accent1);
|
||
let row = index / 7;
|
||
let mut st = ShapeStyle::accent(slot);
|
||
let c = ColorRef::scheme(slot);
|
||
match row {
|
||
0 => {
|
||
// Colored outline, light fill.
|
||
st.fill_ref = (1, ColorRef::scheme(SchemeSlot::Lt1));
|
||
st.line_ref = (2, c);
|
||
st.font_ref.1 = Some(ColorRef::scheme(SchemeSlot::Dk1));
|
||
(st, None, None)
|
||
}
|
||
1 => (st, None, None),
|
||
2 => {
|
||
st.fill_ref = (1, c.clone().with(ColorTransform::LumMod(20000)).with(ColorTransform::LumOff(80000)));
|
||
st.line_ref = (0, c.clone());
|
||
st.font_ref.1 = Some(c.with(ColorTransform::LumMod(50000)));
|
||
(st, None, Some(Line::none()))
|
||
}
|
||
3 => {
|
||
st.fill_ref = (2, c);
|
||
(st, None, Some(Line::none()))
|
||
}
|
||
4 => {
|
||
st.fill_ref = (3, c.clone());
|
||
st.effect_ref = (3, c);
|
||
(st, None, Some(Line::none()))
|
||
}
|
||
_ => {
|
||
st.fill_ref = (1, c.clone().with(ColorTransform::LumMod(75000)));
|
||
st.line_ref = (1, ColorRef::scheme(SchemeSlot::Lt1));
|
||
(st, None, None)
|
||
}
|
||
}
|
||
}
|
||
|
||
fn quick_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let i = usize_param(p, "index").unwrap_or(1).min(41);
|
||
let (st, f, l) = quick_style_for(i);
|
||
edit_shapes(s, p, "shape.quickStyle", |sh| {
|
||
sh.style = Some(st.clone());
|
||
sh.fill.clone_from(&f);
|
||
sh.line.clone_from(&l);
|
||
sh.effects = None;
|
||
if let Some(t) = sh.text.as_mut() {
|
||
deckcraft_model::edit::format_all(t, &|r| r.fill = None);
|
||
}
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let name = str_param(p, "name").ok_or_else(|| bad("shape.rename", "missing `name`"))?.to_string();
|
||
edit_shapes(s, p, "shape.rename", |sh| {
|
||
sh.name = name.clone();
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
fn alt_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let text = str_param(p, "text").unwrap_or("").to_string();
|
||
let deco = bool_param(p, "decorative");
|
||
edit_shapes(s, p, "shape.altText", |sh| {
|
||
sh.descr = text.clone();
|
||
if let Some(d) = deco {
|
||
sh.decorative = d;
|
||
}
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
fn visible(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let v = bool_param(p, "visible");
|
||
edit_shapes(s, p, "shape.visible", |sh| {
|
||
sh.hidden = !v.unwrap_or(sh.hidden);
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
/// Lock: a locked object can be selected, formatted and typed in, but not moved, sized, rotated,
|
||
/// flipped, reshaped or cropped. Locking or unlocking a group does the same to everything in it
|
||
/// (a child can still be unlocked on its own afterwards), as PowerPoint does.
|
||
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let v = bool_param(p, "locked");
|
||
edit_shapes(s, p, "shape.lock", |sh| {
|
||
let to = v.unwrap_or(!sh.locked);
|
||
set_locked(sh, to, 0);
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
fn set_locked(sh: &mut deckcraft_model::Shape, to: bool, depth: usize) {
|
||
sh.locked = to;
|
||
if depth > 64 {
|
||
return;
|
||
}
|
||
if let Some(children) = sh.children_mut() {
|
||
for c in children {
|
||
set_locked(c, to, depth + 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn set_default(s: &mut Session, _p: &Value) -> Result<Value> {
|
||
let st = s.doc()?;
|
||
let Some(sh) = st.selected_shapes().first().map(|x| (*x).clone()) else { return ok() };
|
||
// New shapes drawn in this session take this look (Set as Default Shape).
|
||
s.default_look = Some(super::format::Painted {
|
||
run: sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone()),
|
||
para: None,
|
||
fill: sh.fill.clone(),
|
||
line: sh.line.clone(),
|
||
effects: sh.effects.clone(),
|
||
style: sh.style.clone(),
|
||
});
|
||
ok()
|
||
}
|
||
|
||
fn crop(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let c = [f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom")];
|
||
edit_geometry(s, p, "picture.crop", |sh| {
|
||
if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind {
|
||
for (i, v) in c.iter().enumerate() {
|
||
if let (Some(v), Some(slot)) = (v, fill.crop.get_mut(i)) {
|
||
*slot = v.clamp(-10.0, 0.95);
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
fn picture_adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||
edit_shapes(s, p, "picture.adjust", |sh| {
|
||
if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind {
|
||
let a = &mut fill.adjust;
|
||
if bool_or(p, "reset", false) {
|
||
*a = Default::default();
|
||
}
|
||
if let Some(v) = f64_param(p, "brightness") {
|
||
a.brightness = v.clamp(-1.0, 1.0);
|
||
}
|
||
if let Some(v) = f64_param(p, "contrast") {
|
||
a.contrast = v.clamp(-1.0, 1.0);
|
||
}
|
||
if let Some(v) = f64_param(p, "saturation") {
|
||
a.saturation = Some(v.clamp(0.0, 4.0));
|
||
}
|
||
if let Some(v) = bool_param(p, "grayscale") {
|
||
a.grayscale = v;
|
||
}
|
||
if let Some(v) = f64_param(p, "transparency") {
|
||
fill.alpha = Some((1.0 - v).clamp(0.0, 1.0));
|
||
}
|
||
}
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
fn picture_reset(s: &mut Session, p: &Value) -> Result<Value> {
|
||
edit_shapes(s, p, "picture.reset", |sh| {
|
||
if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind {
|
||
fill.adjust = Default::default();
|
||
fill.crop = [0.0; 4];
|
||
fill.alpha = None;
|
||
if let Some(a) = fill.artistic.take() {
|
||
fill.media = a.original;
|
||
}
|
||
}
|
||
sh.effects = None;
|
||
sh.line = None;
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
fn picture_change(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let (name, bytes) = super::insert::media_bytes(p, "picture.change")?;
|
||
let ct = super::insert::content_type(&name, &bytes);
|
||
let media = s.edit(|doc, _| doc.add_media(&name, ct, bytes).ok_or_else(|| bad("picture.change", NO_ID_SPACE)))?;
|
||
edit_shapes(s, p, "picture.change", |sh| {
|
||
if let deckcraft_model::ShapeKind::Picture { fill } = &mut sh.kind {
|
||
fill.media = media;
|
||
fill.crop = [0.0; 4];
|
||
fill.artistic = None;
|
||
}
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
fn media_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||
edit_shapes(s, p, "media.options", |sh| {
|
||
if let deckcraft_model::ShapeKind::Media(m) = &mut sh.kind {
|
||
if let Some(v) = bool_param(p, "autoplay") {
|
||
m.autoplay = v;
|
||
}
|
||
if let Some(v) = bool_param(p, "loop") {
|
||
m.loop_play = v;
|
||
}
|
||
if let Some(v) = bool_param(p, "rewind") {
|
||
m.rewind = v;
|
||
}
|
||
if let Some(v) = bool_param(p, "acrossSlides") {
|
||
m.play_across_slides = v;
|
||
}
|
||
if let Some(v) = bool_param(p, "hide") {
|
||
m.hide_while_not_playing = v;
|
||
}
|
||
if let Some(v) = bool_param(p, "fullScreen") {
|
||
m.full_screen = v;
|
||
}
|
||
if let Some(v) = f64_param(p, "volume") {
|
||
m.volume = v.clamp(0.0, 1.0);
|
||
}
|
||
let ms = |k: &str| p.get(k).and_then(Value::as_u64).map(|v| v.min(u32::MAX as u64) as u32);
|
||
if let Some(v) = ms("trimStart") {
|
||
m.trim_start_ms = v;
|
||
}
|
||
if let Some(v) = ms("trimEnd") {
|
||
m.trim_end_ms = v;
|
||
}
|
||
if let Some(v) = ms("fadeIn") {
|
||
m.fade_in_ms = v;
|
||
}
|
||
if let Some(v) = ms("fadeOut") {
|
||
m.fade_out_ms = v;
|
||
}
|
||
}
|
||
Ok(())
|
||
})?;
|
||
Ok(json!({}))
|
||
}
|
||
|
||
fn sites(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let id = id_param(p, "id", "shape.sites")?.ok_or_else(|| bad("shape.sites", "missing `id`"))?;
|
||
let st = s.doc()?;
|
||
let sh = st.shape(id).ok_or_else(|| bad("shape.sites", "no such shape"))?;
|
||
Ok(json!(crate::connect::sites(&st.doc, &st.selection, sh).iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()))
|
||
}
|
||
|
||
fn connect(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let (Some(from), Some(to)) = (id_param(p, "from", "shape.connect")?, id_param(p, "to", "shape.connect")?) else {
|
||
return Err(bad("shape.connect", "missing `from` / `to`"));
|
||
};
|
||
let st = s.doc()?;
|
||
let site_list = |id| st.shape(id).map(|sh| crate::connect::sites(&st.doc, &st.selection, sh)).unwrap_or_default();
|
||
let (a, b) = (site_list(from), site_list(to));
|
||
if a.is_empty() || b.is_empty() {
|
||
return Err(bad("shape.connect", "both shapes need connection sites (lines and groups have none)"));
|
||
}
|
||
let pick = |v: &[deckcraft_geom::Point], key: &str| usize_param(p, key).filter(|i| *i < v.len());
|
||
let (ia, ib) = match (pick(&a, "fromSite"), pick(&b, "toSite")) {
|
||
(Some(i), Some(j)) => (i, j),
|
||
(fi, fj) => {
|
||
let mut best = (f64::MAX, 0, 0);
|
||
for (i, pa) in a.iter().enumerate().filter(|(i, _)| fi.is_none_or(|f| f == *i)) {
|
||
for (j, pb) in b.iter().enumerate().filter(|(j, _)| fj.is_none_or(|f| f == *j)) {
|
||
let d = (*pa - *pb).hypot();
|
||
if d < best.0 {
|
||
best = (d, i, j);
|
||
}
|
||
}
|
||
}
|
||
(best.1, best.2)
|
||
}
|
||
};
|
||
let (p0, p1) = (a[ia], b[ib]);
|
||
let id = match id_param(p, "id", "shape.connect")? {
|
||
Some(id) => id,
|
||
None => {
|
||
let preset = str_param(p, "preset").unwrap_or("straightConnector1");
|
||
let r = s.execute("shape.insert", &json!({"preset": preset, "rect": [p0.x, p0.y, 1.0, 1.0]}))?;
|
||
deckcraft_model::ShapeId(r.get("id").and_then(Value::as_u64).ok_or_else(|| bad("shape.connect", "insert failed"))? as u32)
|
||
}
|
||
};
|
||
s.edit(|doc, sel| {
|
||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.connect", "no slide"))?;
|
||
let sh = deckcraft_model::find_shape_mut(list, id).ok_or_else(|| bad("shape.connect", "no such line"))?;
|
||
if !sh.is_line() {
|
||
return Err(bad("shape.connect", "`id` is not a line or connector"));
|
||
}
|
||
sh.xfrm = Some(crate::tools::line_xfrm(p0, p1, 0.0));
|
||
sh.kind = deckcraft_model::ShapeKind::Connector { start: Some((from, ia as u32)), end: Some((to, ib as u32)) };
|
||
Ok(())
|
||
})?;
|
||
Ok(json!({"id": id.0, "fromSite": ia, "toSite": ib}))
|
||
}
|
||
|
||
/// Path data through `pts` (shape-local): straight segments, or a Catmull-Rom curve as cubics.
|
||
pub fn freeform_path(pts: &[deckcraft_geom::Point], closed: bool, smooth: bool) -> String {
|
||
let f = |v: f64| {
|
||
let s = format!("{:.2}", v);
|
||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||
};
|
||
let mut d = format!("M {} {}", f(pts[0].x), f(pts[0].y));
|
||
let n = pts.len();
|
||
if smooth && n > 2 {
|
||
let at = |i: isize| -> deckcraft_geom::Point {
|
||
if closed { pts[i.rem_euclid(n as isize) as usize] } else { pts[i.clamp(0, n as isize - 1) as usize] }
|
||
};
|
||
let segs = if closed { n } else { n - 1 };
|
||
for i in 0..segs as isize {
|
||
let (p0, p1, p2, p3) = (at(i - 1), at(i), at(i + 1), at(i + 2));
|
||
let c1 = p1 + (p2 - p0) / 6.0;
|
||
let c2 = p2 - (p3 - p1) / 6.0;
|
||
d += &format!(" C {} {} {} {} {} {}", f(c1.x), f(c1.y), f(c2.x), f(c2.y), f(p2.x), f(p2.y));
|
||
}
|
||
} else {
|
||
for q in &pts[1..] {
|
||
d += &format!(" L {} {}", f(q.x), f(q.y));
|
||
}
|
||
}
|
||
if closed {
|
||
d += " Z";
|
||
}
|
||
d
|
||
}
|
||
|
||
fn freeform(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let pts: Vec<deckcraft_geom::Point> = p
|
||
.get("points")
|
||
.and_then(Value::as_array)
|
||
.map(|a| {
|
||
a.iter()
|
||
.filter_map(|v| Some(deckcraft_geom::Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?)))
|
||
.filter(|q| q.x.is_finite() && q.y.is_finite())
|
||
.take(20_000)
|
||
.collect()
|
||
})
|
||
.unwrap_or_default();
|
||
if pts.len() < 2 {
|
||
return Err(bad("shape.freeform", "need at least two points"));
|
||
}
|
||
let closed = bool_or(p, "closed", false) && pts.len() > 2;
|
||
let smooth = bool_or(p, "smooth", false);
|
||
let mut bb = deckcraft_geom::Rect::from_points(pts[0], pts[0]);
|
||
for q in &pts {
|
||
bb = bb.union_pt(*q);
|
||
}
|
||
let (w, h) = (bb.width().max(1.0), bb.height().max(1.0));
|
||
let local: Vec<deckcraft_geom::Point> = pts.iter().map(|q| deckcraft_geom::Point::new(q.x - bb.x0, q.y - bb.y0)).collect();
|
||
let d = freeform_path(&local, closed, smooth);
|
||
// Closed shapes take the default shape style, open paths the line style.
|
||
let r = s.execute("shape.insert", &json!({"preset": if closed { "rect" } else { "line" }, "rect": [bb.x0, bb.y0, w, h]}))?;
|
||
let id = deckcraft_model::ShapeId(r.get("id").and_then(Value::as_u64).ok_or_else(|| bad("shape.freeform", "insert failed"))? as u32);
|
||
s.edit(|doc, sel| {
|
||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.freeform", "no slide"))?;
|
||
let sh = deckcraft_model::find_shape_mut(list, id).ok_or_else(|| bad("shape.freeform", "insert failed"))?;
|
||
sh.geom = Geom::Custom {
|
||
paths: vec![deckcraft_model::CustomPath {
|
||
w,
|
||
h,
|
||
d: d.clone(),
|
||
fill: if closed { deckcraft_geom::preset::FillMode::Norm } else { deckcraft_geom::preset::FillMode::None },
|
||
stroke: true,
|
||
}],
|
||
};
|
||
sh.xfrm = Some(deckcraft_geom::Xfrm::new(bb.x0, bb.y0, w, h));
|
||
sh.kind = deckcraft_model::ShapeKind::Shape;
|
||
sh.name = format!("Freeform {}", id.0);
|
||
Ok(())
|
||
})?;
|
||
Ok(json!({"id": id.0}))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod size_position_tests {
|
||
use serde_json::json;
|
||
|
||
use crate::Session;
|
||
|
||
fn deck() -> (Session, u32) {
|
||
let mut s = Session::with_new();
|
||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 50.0, 200.0, 100.0]})).unwrap()["id"].as_u64().unwrap() as u32;
|
||
(s, id)
|
||
}
|
||
|
||
fn geom(s: &mut Session, id: u32) -> (f64, f64, f64, f64, f64) {
|
||
let v = s.execute("shape.inspect", &json!({"id": id})).unwrap();
|
||
let b = &v["box"];
|
||
(b["x"].as_f64().unwrap(), b["y"].as_f64().unwrap(), b["w"].as_f64().unwrap(), b["h"].as_f64().unwrap(), b["rot"].as_f64().unwrap_or(0.0))
|
||
}
|
||
|
||
fn undos(s: &Session) -> usize {
|
||
s.doc().unwrap().history.undo.len()
|
||
}
|
||
|
||
#[test]
|
||
fn sets_position_size_and_rotation_in_one_undo() {
|
||
let (mut s, id) = deck();
|
||
let n = undos(&s);
|
||
s.execute("shape.sizePosition", &json!({"x": 80.0, "y": 90.0, "w": 240.0, "h": 60.0, "rot": 370.0})).unwrap();
|
||
assert_eq!(geom(&mut s, id), (80.0, 90.0, 240.0, 60.0, 10.0));
|
||
assert_eq!(undos(&s), n + 1);
|
||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Size and Position…");
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(geom(&mut s, id), (40.0, 50.0, 200.0, 100.0, 0.0));
|
||
}
|
||
|
||
#[test]
|
||
fn lock_aspect_and_scale_share_one_step() {
|
||
let (mut s, id) = deck();
|
||
s.execute("shape.sizePosition", &json!({"w": 300.0, "lockAspect": true})).unwrap();
|
||
assert_eq!(geom(&mut s, id), (40.0, 50.0, 300.0, 150.0, 0.0));
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
s.execute("shape.sizePosition", &json!({"h": 50.0, "lockAspect": true})).unwrap();
|
||
assert_eq!(geom(&mut s, id), (40.0, 50.0, 100.0, 50.0, 0.0));
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
s.execute("shape.sizePosition", &json!({"scaleW": 25.0, "scaleH": 150.0})).unwrap();
|
||
assert_eq!(geom(&mut s, id), (40.0, 50.0, 50.0, 150.0, 0.0));
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
s.execute("shape.sizePosition", &json!({"scaleH": 50.0, "lockAspect": true})).unwrap();
|
||
assert_eq!(geom(&mut s, id), (40.0, 50.0, 100.0, 50.0, 0.0));
|
||
// An explicit width wins over a scale on the same axis.
|
||
s.execute("shape.sizePosition", &json!({"w": 80.0, "scaleW": 50.0})).unwrap();
|
||
assert_eq!(geom(&mut s, id).2, 80.0);
|
||
}
|
||
|
||
#[test]
|
||
fn partial_fields_leave_the_rest_and_a_repeat_does_not_undo() {
|
||
let (mut s, id) = deck();
|
||
let n = undos(&s);
|
||
s.execute("shape.sizePosition", &json!({"y": 12.0, "rot": -90.0})).unwrap();
|
||
assert_eq!(geom(&mut s, id), (40.0, 12.0, 200.0, 100.0, 270.0));
|
||
assert_eq!(undos(&s), n + 1);
|
||
let n = undos(&s);
|
||
s.execute("shape.sizePosition", &json!({"x": 40.0, "y": 12.0, "w": 200.0, "h": 100.0, "rot": 270.0})).unwrap();
|
||
assert_eq!(undos(&s), n, "identical geometry is not an undo step");
|
||
}
|
||
|
||
#[test]
|
||
fn no_fields_opens_the_dialog_without_editing() {
|
||
let (mut s, id) = deck();
|
||
let n = undos(&s);
|
||
s.execute("shape.sizePosition", &json!({})).unwrap();
|
||
assert_eq!(geom(&mut s, id), (40.0, 50.0, 200.0, 100.0, 0.0));
|
||
assert_eq!(undos(&s), n);
|
||
let req = s.ui_requests.last().unwrap();
|
||
match req {
|
||
crate::UiRequest::Dialog { id, params } => {
|
||
assert_eq!(id, "sizePosition");
|
||
assert_eq!(params["w"], 200.0);
|
||
assert_eq!(params["h"], 100.0);
|
||
assert_eq!(params["x"], 40.0);
|
||
assert_eq!(params["rot"], 0.0);
|
||
assert_eq!(params["scaleW"], 100.0);
|
||
assert_eq!(params["lockAspect"], false);
|
||
}
|
||
other => panic!("expected a dialog, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn bad_fields_do_not_edit() {
|
||
let (mut s, id) = deck();
|
||
let n = undos(&s);
|
||
for p in [
|
||
json!({"w": "wide"}),
|
||
json!({"h": -5.0}),
|
||
json!({"scaleW": 0.0}),
|
||
json!({"x": 1e9}),
|
||
json!({"rot": "spin"}),
|
||
json!({"lockAspect": "yes"}),
|
||
] {
|
||
let err = s.execute("shape.sizePosition", &p).unwrap_err();
|
||
assert!(err.to_string().contains("sizePosition"), "{err}");
|
||
assert_eq!(geom(&mut s, id), (40.0, 50.0, 200.0, 100.0, 0.0));
|
||
assert_eq!(undos(&s), n);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn scale_is_per_shape_and_nothing_selected_is_disabled() {
|
||
let (mut s, a) = deck();
|
||
let b = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [10.0, 10.0, 40.0, 80.0]})).unwrap()["id"].as_u64().unwrap() as u32;
|
||
s.execute("shape.sizePosition", &json!({"ids": [a, b], "scaleW": 200.0})).unwrap();
|
||
assert_eq!(geom(&mut s, a).2, 400.0);
|
||
assert_eq!(geom(&mut s, a).3, 100.0);
|
||
assert_eq!(geom(&mut s, b).2, 80.0);
|
||
assert_eq!(geom(&mut s, b).3, 80.0);
|
||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Size and Position…");
|
||
s.execute("edit.select", &json!({"ids": []})).unwrap();
|
||
let err = s.execute("shape.sizePosition", &json!({})).unwrap_err();
|
||
assert!(err.to_string().contains("nothing selected"), "{err}");
|
||
}
|
||
}
|
||
|
||
/// Draw › Ink to Shape: each recognisable stroke of the slide's ink becomes a preset shape
|
||
/// outlined in the stroke's colour and width; the rest stays ink. One undo step.
|
||
fn ink_to_shape(s: &mut Session, _p: &Value) -> Result<Value> {
|
||
use crate::inkshape::{Recognized, recognize};
|
||
use deckcraft_model::{Shape, ShapeId, ShapeKind};
|
||
let shapes = s.doc()?.shapes();
|
||
let Some(ink) = shapes.iter().find(|sh| matches!(sh.kind, ShapeKind::Ink { .. })) else {
|
||
return Ok(json!({"shapes": [], "kept": 0}));
|
||
};
|
||
let (ink_id, off) = (ink.id, ink.xfrm.map(|x| (x.x, x.y)).unwrap_or((0.0, 0.0)));
|
||
let ShapeKind::Ink { strokes } = &ink.kind else { return Ok(json!({"shapes": [], "kept": 0})) };
|
||
let found: Vec<Option<Recognized>> = strokes
|
||
.iter()
|
||
.map(|st| if st.highlighter { None } else { recognize(&st.points.iter().map(|(x, y, _)| (x + off.0, y + off.1)).collect::<Vec<_>>()) })
|
||
.collect();
|
||
if found.iter().all(Option::is_none) {
|
||
return Ok(json!({"shapes": [], "kept": strokes.len()}));
|
||
}
|
||
s.edit(|doc, sel| {
|
||
let mut new = vec![];
|
||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?;
|
||
let Some(pos) = list.iter().position(|sh| sh.id == ink_id) else { return Err(bad("draw.inkToShape", "ink gone")) };
|
||
let ShapeKind::Ink { strokes } = &mut list[pos].kind else { return Err(bad("draw.inkToShape", "not ink")) };
|
||
let all = std::mem::take(strokes);
|
||
let mut kept = vec![];
|
||
let mut made = vec![];
|
||
for (st, r) in all.into_iter().zip(found.iter()) {
|
||
let Some(r) = r else {
|
||
kept.push(st);
|
||
continue;
|
||
};
|
||
let (preset, xfrm) = match *r {
|
||
Recognized::Line { x0, y0, x1, y1 } => {
|
||
("line", crate::tools::line_xfrm(deckcraft_geom::Point::new(x0, y0), deckcraft_geom::Point::new(x1, y1), 0.0))
|
||
}
|
||
Recognized::Rect { x, y, w, h } => ("rect", deckcraft_geom::Xfrm::new(x, y, w, h)),
|
||
Recognized::Ellipse { x, y, w, h } => ("ellipse", deckcraft_geom::Xfrm::new(x, y, w, h)),
|
||
Recognized::Triangle { x, y, w, h, flip_v } => ("triangle", deckcraft_geom::Xfrm { flip_v, ..deckcraft_geom::Xfrm::new(x, y, w, h) }),
|
||
};
|
||
made.push((preset, xfrm, st.color, st.width));
|
||
}
|
||
let remaining = kept.len();
|
||
*strokes = kept;
|
||
if remaining == 0 {
|
||
list.remove(pos);
|
||
}
|
||
for (preset, xfrm, color, width) in made {
|
||
let id = ShapeId(doc.alloc_id().ok_or_else(|| bad("draw.inkToShape", NO_ID_SPACE))?);
|
||
let name = format!(
|
||
"{} {}",
|
||
match preset {
|
||
"line" => "Straight Connector",
|
||
"rect" => "Rectangle",
|
||
"ellipse" => "Oval",
|
||
_ => "Isosceles Triangle",
|
||
},
|
||
id.0
|
||
);
|
||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("draw.inkToShape", "no slide"))?;
|
||
list.push(Shape {
|
||
id,
|
||
name,
|
||
xfrm: Some(xfrm),
|
||
geom: Geom::preset(preset),
|
||
fill: Some(Fill::None),
|
||
line: Some(Line::solid(ColorRef::rgb(color), width.clamp(0.25, 100.0))),
|
||
kind: ShapeKind::Shape,
|
||
..Default::default()
|
||
});
|
||
new.push(id);
|
||
}
|
||
sel.shapes = new.clone();
|
||
Ok(json!({"shapes": new.iter().map(|i| i.0).collect::<Vec<_>>(), "kept": remaining}))
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
#![allow(clippy::unwrap_used)]
|
||
|
||
use super::*;
|
||
use serde_json::json;
|
||
|
||
fn nested_group() -> (Session, Vec<ShapeId>, ShapeId, ShapeId) {
|
||
let mut s = Session::with_new();
|
||
let mut ids = vec![];
|
||
for i in 0..4 {
|
||
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40 + i * 100, 100, 80, 60]})).unwrap()["id"].as_u64().unwrap();
|
||
s.execute("shape.line", &json!({"width": i + 1, "dash": "dash"})).unwrap();
|
||
ids.push(ShapeId(id as u32));
|
||
}
|
||
let group = |s: &mut Session, ids: &[ShapeId]| {
|
||
s.execute("edit.select", &json!({"ids": ids})).unwrap();
|
||
ShapeId(s.execute("arrange.group", &json!({})).unwrap()["id"].as_u64().unwrap() as u32)
|
||
};
|
||
let inner = group(&mut s, &ids[..2]);
|
||
let outer = group(&mut s, &[inner, ids[2]]);
|
||
(s, ids[..3].to_vec(), outer, ids[3])
|
||
}
|
||
|
||
#[test]
|
||
fn group_fill_reaches_nested_members_in_one_undo_step() {
|
||
let (mut s, members, group, outside) = nested_group();
|
||
let before = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
|
||
let untouched = serde_json::to_value(s.doc().unwrap().shape(outside)).unwrap();
|
||
s.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap();
|
||
for id in &members {
|
||
assert_eq!(s.doc().unwrap().shape(*id).unwrap().fill, Some(Fill::solid(ColorRef::rgb(Rgba::rgb(255, 0, 0)))));
|
||
}
|
||
assert_eq!(s.doc().unwrap().selection.shapes, vec![group]);
|
||
assert_eq!(serde_json::to_value(s.doc().unwrap().shape(outside)).unwrap(), untouched);
|
||
let filled = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), before);
|
||
s.execute("edit.redo", &json!({})).unwrap();
|
||
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), filled);
|
||
|
||
s.execute("shape.fill", &json!({"none": true})).unwrap();
|
||
assert!(members.iter().all(|id| s.doc().unwrap().shape(*id).unwrap().fill == Some(Fill::None)));
|
||
s.execute("shape.fill", &json!({"reset": true})).unwrap();
|
||
assert!(members.iter().all(|id| s.doc().unwrap().shape(*id).unwrap().fill.is_none()));
|
||
}
|
||
|
||
#[test]
|
||
fn group_outline_reaches_members_without_resetting_their_other_properties() {
|
||
let (mut s, members, group, outside) = nested_group();
|
||
let before = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
|
||
// Explicit ids work even when another object is selected.
|
||
s.execute("edit.select", &json!({"ids": [outside]})).unwrap();
|
||
s.execute("shape.line", &json!({"ids": [group], "color": "#00ff00"})).unwrap();
|
||
for (i, id) in members.iter().enumerate() {
|
||
let line = s.doc().unwrap().shape(*id).unwrap().line.as_ref().unwrap();
|
||
assert_eq!(line.fill, Some(Fill::solid(ColorRef::rgb(Rgba::rgb(0, 255, 0)))));
|
||
assert_eq!(line.width, Some((i + 1) as f64));
|
||
assert_eq!(line.dash, Some(Dash::Preset { name: "dash".into() }));
|
||
}
|
||
assert_eq!(s.doc().unwrap().selection.shapes, vec![outside]);
|
||
let outlined = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), before);
|
||
s.execute("edit.redo", &json!({})).unwrap();
|
||
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), outlined);
|
||
s.execute("shape.line", &json!({"ids": [group], "none": true})).unwrap();
|
||
assert!(members.iter().all(|id| s.doc().unwrap().shape(*id).unwrap().line.as_ref().unwrap().fill == Some(Fill::None)));
|
||
s.execute("shape.line", &json!({"ids": [group], "reset": true})).unwrap();
|
||
assert!(members.iter().all(|id| s.doc().unwrap().shape(*id).unwrap().line.is_none()));
|
||
}
|
||
|
||
#[test]
|
||
fn group_fill_transparency_keeps_each_members_inherited_colour() {
|
||
let (mut s, members, group, _) = nested_group();
|
||
s.execute("shape.fill", &json!({"ids": [members[1]], "color": "#00ff00"})).unwrap();
|
||
let colours: Vec<_> = {
|
||
let st = s.doc().unwrap();
|
||
let ctx = super::super::text::ctx_for(&st.doc, &st.selection).unwrap();
|
||
members
|
||
.iter()
|
||
.map(|id| {
|
||
let (fill, ph) = deckcraft_model::resolve::fill(&ctx, st.shape(*id).unwrap());
|
||
let Some(Fill::Solid { color }) = fill else { panic!("solid theme fill") };
|
||
ctx.color(&color, ph)
|
||
})
|
||
.collect()
|
||
};
|
||
s.execute("shape.fill", &json!({"ids": [group], "transparency": 0.5})).unwrap();
|
||
for (id, colour) in members.iter().zip(colours) {
|
||
let mut expected = ColorRef::rgb(colour);
|
||
expected.mods.push(ColorTransform::Alpha(50000));
|
||
assert_eq!(s.doc().unwrap().shape(*id).unwrap().fill, Some(Fill::solid(expected)));
|
||
}
|
||
}
|
||
|
||
fn rotation(s: &Session) -> f64 {
|
||
let st = s.active().unwrap();
|
||
let id = st.selection.shapes[0];
|
||
st.shape(id).unwrap().xfrm.unwrap().rot
|
||
}
|
||
|
||
#[test]
|
||
fn a_twist_is_one_undo_and_wraps_past_a_full_turn() {
|
||
let mut s = Session::with_new();
|
||
s.execute("edit.select", &json!({"ids": []})).unwrap();
|
||
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40.0, 40.0, 80.0, 40.0]})).unwrap();
|
||
let id = id.get("id").and_then(Value::as_u64).unwrap();
|
||
s.execute("edit.select", &json!({"ids": [id]})).unwrap();
|
||
assert!((rotation(&s) - 0.0).abs() < 0.01);
|
||
|
||
s.execute("shape.rotate", &json!({"by": 10.0, "gesture": "move"})).unwrap();
|
||
s.execute("shape.rotate", &json!({"by": 350.0, "gesture": "move"})).unwrap();
|
||
assert!((rotation(&s) - 0.0).abs() < 0.01, "10 + 350 wraps to 0, got {}", rotation(&s));
|
||
s.execute("shape.rotate", &json!({"by": 20.0, "gesture": "move"})).unwrap();
|
||
s.execute("shape.rotate", &json!({"gesture": "end", "ids": []})).unwrap();
|
||
assert!((rotation(&s) - 20.0).abs() < 0.01);
|
||
assert_eq!(s.active().unwrap().history.undo.last().unwrap().label, "Rotation");
|
||
assert!(s.active().unwrap().interaction.is_none());
|
||
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert!((rotation(&s) - 0.0).abs() < 0.01, "one undo undoes the whole twist");
|
||
s.execute("edit.redo", &json!({})).unwrap();
|
||
assert!((rotation(&s) - 20.0).abs() < 0.01);
|
||
}
|
||
|
||
#[test]
|
||
fn ending_a_twist_does_not_commit_a_drag() {
|
||
let mut s = Session::with_new();
|
||
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [10.0, 10.0, 30.0, 30.0]})).unwrap();
|
||
let st = s.active_mut().unwrap();
|
||
st.interaction = Some(crate::Interaction { label: "Move".into(), doc: st.doc.clone(), selection: st.selection.clone() });
|
||
s.execute("shape.rotate", &json!({"gesture": "end", "ids": []})).unwrap();
|
||
assert_eq!(s.active().unwrap().interaction.as_ref().unwrap().label, "Move");
|
||
}
|
||
}
|