mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 04:16:46 -06:00
M4: live boolean editing, arc fields, shape builder on strokes, one-sided widths (#111)
Boolean operands are editable in place with live results (per-operand operations, cached prefixes so a drag recomputes from the edited operand up); a Design panel Arc section; the shape builder takes strokes and open paths as outlined areas; variable width points can be wider on one side.
This commit is contained in:
@@ -21,7 +21,8 @@ fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (l
|
||||
(widthAndHeight|height|none|truncate), textDecoration, textCase (original|upper|lower|title|smallCaps), paragraphSpacing, paragraphIndent, \
|
||||
listStyle (none|bulleted|numbered), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \
|
||||
fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), pointCount, innerRadius, arcStart, arcSweep, \
|
||||
arcInner, constrainProportions, description, interactions, overflow, pluginData {k: v}";
|
||||
arcInner, booleanOperation (a boolean group's operation, or an operand's own; null: the group's), operandOperation (a layer in a boolean \
|
||||
group: how it combines with the operands below it; null: the group's), constrainProportions, description, interactions, overflow, pluginData {k: v}";
|
||||
|
||||
/// camelCase object keys → snake_case (accept the inspect/camel forms for serde-backed values).
|
||||
pub fn snake(v: &Value) -> Value {
|
||||
@@ -117,30 +118,72 @@ fn four(v: &Value) -> Option<[f64; 4]> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const WIDTH_PROFILE_HELP: &str = "widthProfile: a preset (uniform|wedge|taper|eye|rounded|pinch), points [[t (0-1 along the path), scale (× stroke weight)], ...] or [{t, scale}], {preset} | {points}, or null (uniform)";
|
||||
const BOOL_OP_HELP: &str = "booleanOperation / operandOperation: union|subtract|intersect|exclude (null: the group's, for operands)";
|
||||
|
||||
/// A width profile from a preset name, points (`[[t, scale], ...]` or `[{t, scale}]`), `{preset}`
|
||||
/// / `{points}`, or null. `Some(None)` = uniform; `None` = not a profile.
|
||||
/// An operand's own boolean operation (`null`: its group's).
|
||||
fn set_operand_op(doc: &mut Document, id: NodeId, v: &Value, cmd: &str) -> Result<()> {
|
||||
if crate::booleans::group_of(doc, id).is_none() {
|
||||
return Err(bad(cmd, "not a boolean group or a layer in one"));
|
||||
}
|
||||
doc.get_mut(id)?.bool_op = if v.is_null() { None } else { Some(enum_of(v).ok_or_else(|| bad(cmd, BOOL_OP_HELP))?) };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) const WIDTH_PROFILE_HELP: &str = "widthProfile: a preset (uniform|wedge|taper|eye|rounded|pinch), points [[t (0-1 along the path), scale (× stroke weight)] | [t, left, right] (one-sided: each side's scale, looking along the path), ...] or [{t, scale} | {t, left, right}], {preset} | {points}, or null (uniform)";
|
||||
|
||||
/// A width profile from a preset name, points (`[[t, scale] | [t, left, right], ...]` or
|
||||
/// `[{t, scale} | {t, left, right}]`), `{preset}` / `{points}`, or null. `Some(None)` = uniform;
|
||||
/// `None` = not a profile.
|
||||
pub fn width_profile_of(v: &Value) -> Option<Option<WidthProfile>> {
|
||||
match v {
|
||||
Value::Null => Some(None),
|
||||
Value::String(s) => WidthPreset::from_id(s).map(WidthPreset::profile),
|
||||
Value::Array(a) => {
|
||||
let pts = a
|
||||
let num = |x: Option<&Value>| x.and_then(f64_of);
|
||||
let points = a
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
Value::Array(xy) => Some((xy.first().and_then(f64_of)?, xy.get(1).and_then(f64_of)?)),
|
||||
Value::Object(o) => Some((o.get("t").and_then(f64_of)?, o.get("scale").or_else(|| o.get("width")).and_then(f64_of)?)),
|
||||
Value::Array(xy) => match (num(xy.first()), num(xy.get(1)), num(xy.get(2))) {
|
||||
(Some(t), Some(l), Some(r)) => Some(WidthPoint::from_sides(t, l, r)),
|
||||
(Some(t), Some(s), None) => Some(WidthPoint::new(t, s)),
|
||||
_ => None,
|
||||
},
|
||||
Value::Object(o) => {
|
||||
let t = num(o.get("t"))?;
|
||||
match (num(o.get("left")), num(o.get("right"))) {
|
||||
(Some(l), Some(r)) => Some(WidthPoint::from_sides(t, l, r)),
|
||||
_ => Some(WidthPoint::new(t, num(o.get("scale").or_else(|| o.get("width")))?)),
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
Some(Some(WidthProfile::new(pts)).filter(|p| !p.points.is_empty()))
|
||||
Some(Some(WidthProfile { points }.sanitized()).filter(|p| !p.points.is_empty()))
|
||||
}
|
||||
Value::Object(o) => o.get("points").or_else(|| o.get("preset")).and_then(width_profile_of),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A width point as [`width_profile_of`] reads it back: `[t, scale]`, or `[t, left, right]` when
|
||||
/// its sides differ.
|
||||
pub fn width_point_json(p: &WidthPoint) -> Value {
|
||||
if p.symmetric() {
|
||||
json!([p.t, p.scale])
|
||||
} else {
|
||||
let (l, r) = p.sides();
|
||||
json!([p.t, l, r])
|
||||
}
|
||||
}
|
||||
|
||||
/// A profile's points as [`width_profile_of`] reads them back (`null` without points).
|
||||
pub fn width_points_json(profile: Option<&WidthProfile>) -> Value {
|
||||
match profile.filter(|p| !p.points.is_empty()) {
|
||||
Some(p) => Value::Array(p.points.iter().map(width_point_json).collect()),
|
||||
None => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
const TRACK_HELP: &str = "tracks: [px | \"120px\" | \"1fr\" | \"auto\" (hug) | {sizing: fixed|hug|fill, value}]";
|
||||
|
||||
/// One grid track from `120` / `"120px"` (fixed), `"2fr"` / `"fill"` (fill), `"auto"` / `"hug"`,
|
||||
@@ -597,10 +640,13 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
}
|
||||
}
|
||||
}
|
||||
"booleanOperation" => match &mut doc.get_mut(id)?.kind {
|
||||
NodeKind::BooleanOp { op } => *op = enum_of(v).ok_or_else(|| bad(cmd, "booleanOperation: union|subtract|intersect|exclude"))?,
|
||||
_ => return Err(bad(cmd, "not a boolean group")),
|
||||
},
|
||||
"booleanOperation" if !matches!(doc.get(id)?.kind, NodeKind::BooleanOp { .. }) => set_operand_op(doc, id, v, cmd)?,
|
||||
"booleanOperation" => {
|
||||
if let NodeKind::BooleanOp { op } = &mut doc.get_mut(id)?.kind {
|
||||
*op = enum_of(v).ok_or_else(|| bad(cmd, BOOL_OP_HELP))?;
|
||||
}
|
||||
}
|
||||
"operandOperation" => set_operand_op(doc, id, v, cmd)?,
|
||||
"sectionContentsHidden" => {
|
||||
if let NodeKind::Section { contents_hidden } = &mut doc.get_mut(id)?.kind {
|
||||
*contents_hidden = v.as_bool().unwrap_or(false);
|
||||
|
||||
@@ -37,10 +37,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!("bool.union", "Union selection", "{ids?}", true, |s, p| wrap(s, p, Wrap::Bool(BoolOp::Union))),
|
||||
spec!("bool.subtract", "Subtract selection", "{ids?}", true, |s, p| wrap(s, p, Wrap::Bool(BoolOp::Subtract))),
|
||||
spec!("bool.intersect", "Intersect selection", "{ids?}", true, |s, p| wrap(s, p, Wrap::Bool(BoolOp::Intersect))),
|
||||
spec!("bool.exclude", "Exclude selection", "{ids?}", true, |s, p| wrap(s, p, Wrap::Bool(BoolOp::Exclude))),
|
||||
spec!("bool.union", "Union selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.union", p, BoolOp::Union)),
|
||||
spec!("bool.subtract", "Subtract selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.subtract", p, BoolOp::Subtract)),
|
||||
spec!("bool.intersect", "Intersect selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.intersect", p, BoolOp::Intersect)),
|
||||
spec!("bool.exclude", "Exclude selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.exclude", p, BoolOp::Exclude)),
|
||||
spec!("bool.flatten", "Flatten boolean", "{ids?}", true, flatten),
|
||||
spec!("vector.flatten", "Flatten", "{ids?}", true, flatten),
|
||||
spec!("vector.outlineStroke", "Outline stroke", "{ids?}", true, outline_stroke),
|
||||
@@ -103,6 +103,14 @@ enum Wrap {
|
||||
/// Room a section made around layers leaves on each side.
|
||||
const SECTION_PAD: f64 = 40.0;
|
||||
|
||||
/// What the boolean commands do with what they're given.
|
||||
const BOOL_PARAMS: &str = "{ids?} — layers inside boolean groups: sets their own operation (how each combines with the operands below it); one boolean group: sets its operation (every operand follows it); otherwise wraps the layers in a new boolean group";
|
||||
|
||||
/// Wrap the selection in a new boolean group doing `op`.
|
||||
pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, op: BoolOp) -> Result<Value> {
|
||||
wrap(s, p, Wrap::Bool(op))
|
||||
}
|
||||
|
||||
/// Wrap the selection in a new container at the topmost selected layer's place.
|
||||
fn wrap(s: &mut Session, p: &Value, w: Wrap) -> Result<Value> {
|
||||
let ids = req_targets("object.group", s, p)?;
|
||||
@@ -178,6 +186,10 @@ fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let op = n.opacity;
|
||||
for (k, c) in kids.iter().enumerate() {
|
||||
doc.reparent(*c, parent, Some(idx + k))?;
|
||||
// Released operands no longer combine with anything.
|
||||
if doc.get(*c)?.bool_op.is_some() {
|
||||
doc.get_mut(*c)?.bool_op = None;
|
||||
}
|
||||
if op < 1.0 {
|
||||
let cn = doc.get_mut(*c)?;
|
||||
cn.opacity *= op;
|
||||
|
||||
@@ -681,6 +681,9 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr
|
||||
VariableValue::Color(c) => json!(c.to_css_hex()),
|
||||
// Opacity variables hold percentages.
|
||||
VariableValue::Number(x) if field == "opacity" => json!((x / 100.0).clamp(0.0, 1.0)),
|
||||
// Arc sweep and ratio variables hold percentages too (what the Arc fields show).
|
||||
VariableValue::Number(x) if field == "arcSweep" => json!(x * 3.6),
|
||||
VariableValue::Number(x) if field == "arcInner" => json!(x / 100.0),
|
||||
VariableValue::Number(x) => json!(x),
|
||||
VariableValue::String(x) => json!(x),
|
||||
VariableValue::Boolean(b) => json!(b),
|
||||
|
||||
Reference in New Issue
Block a user