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:
BFlat
2026-10-11 10:04:25 +07:00
committed by GitHub
parent 1444754f66
commit 08d91869cb
47 changed files with 2328 additions and 326 deletions
+68
View File
@@ -0,0 +1,68 @@
//! The Design panel's Arc fields drive the same `node.set` keys as the canvas handles: Start in
//! degrees, Sweep and Ratio as percentages, and number variables bound to them (sweep and ratio
//! variables hold percentages, like the fields).
use std::f64::consts::{PI, TAU};
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use figcraft_doc::{NodeId, NodeKind};
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn arc(s: &Session, id: NodeId) -> (f64, f64, f64) {
match s.doc().unwrap().node(id).unwrap().kind {
NodeKind::Ellipse { start, sweep, inner } => (start, sweep, inner),
_ => panic!("not an ellipse"),
}
}
fn ellipse(s: &mut Session) -> NodeId {
id_of(&ex(s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 100, "height": 100}))["id"]).unwrap()
}
#[test]
fn fields_write_degrees_and_percentages() {
let mut s = Session::new();
let e = ellipse(&mut s);
// What the fields send for Start 90°, Sweep 75% and Ratio 40%.
ex(&mut s, "node.set", json!({"id": e, "props": {"arcStart": 90, "arcSweep": 75.0 * 3.6, "arcInner": 0.4}}));
let (start, sweep, inner) = arc(&s, e);
assert!((start - PI / 2.0).abs() < 1e-9 && (sweep - 0.75 * TAU).abs() < 1e-9 && (inner - 0.4).abs() < 1e-9);
// A negative sweep runs the other way; past a full turn it holds at one.
ex(&mut s, "node.set", json!({"id": e, "props": {"arcSweep": -25.0 * 3.6}}));
assert!((arc(&s, e).1 + 0.25 * TAU).abs() < 1e-9);
ex(&mut s, "node.set", json!({"id": e, "props": {"arcSweep": 900}}));
assert!((arc(&s, e).1 - TAU).abs() < 1e-9);
// Inspect reports the arc the fields show.
ex(&mut s, "node.set", json!({"id": e, "props": {"arcSweep": 180}}));
let n = ex(&mut s, "node.get", json!({"id": e}));
assert!((n["arc"]["sweep"].as_f64().unwrap() - 180.0).abs() < 1e-9);
// Arc keys are for ellipses only.
let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"]).unwrap();
assert!(s.execute("node.set", &json!({"id": r, "props": {"arcSweep": 90}})).is_err());
}
#[test]
fn variables_bound_to_the_arc_hold_what_the_fields_show() {
let mut s = Session::new();
let e = ellipse(&mut s);
ex(&mut s, "variable.createCollection", json!({"name": "Shape", "modes": ["A", "B"]}));
ex(&mut s, "variable.create", json!({"collection": "Shape", "name": "start", "type": "number", "value": 45}));
ex(&mut s, "variable.create", json!({"collection": "Shape", "name": "sweep", "type": "number", "value": 50}));
ex(&mut s, "variable.create", json!({"collection": "Shape", "name": "ratio", "type": "number", "value": 25}));
for (field, var) in [("arcStart", "start"), ("arcSweep", "sweep"), ("arcInner", "ratio")] {
ex(&mut s, "variable.bind", json!({"ids": [e], "field": field, "variable": var}));
}
let (start, sweep, inner) = arc(&s, e);
assert!((start - PI / 4.0).abs() < 1e-9, "degrees: {start}");
assert!((sweep - PI).abs() < 1e-9, "50% of a turn: {sweep}");
assert!((inner - 0.25).abs() < 1e-9, "25%: {inner}");
// A small ratio stays a percentage.
ex(&mut s, "variable.set", json!({"variable": "ratio", "value": 1}));
assert!((arc(&s, e).2 - 0.01).abs() < 1e-9, "{:?}", arc(&s, e));
}
+130
View File
@@ -0,0 +1,130 @@
//! Live boolean editing: a boolean group's operands stay layers of their own.
//!
//! - **Selecting operands.** A click selects the boolean group; a double click (or Cmd/Ctrl-click,
//! or a click while a sibling operand is selected) selects the operand under the pointer, and a
//! double click on a selected shape operand enters vector edit mode on it. Enter selects every
//! operand.
//! - **Live results.** Moving, resizing or editing the points of an operand changes the document
//! on every pointer move, so the group's result is redrawn during the drag (one undo step per
//! gesture); a dragged operand stays in its group. The renderer caches every prefix of the group's fold, so only the operands from the
//! edited one up are recombined ([`figcraft_render::boolean`]). While an operand is selected or
//! edited, the canvas outlines every operand of its group ([`outlined_operands`]).
//! - **Per-operand operations.** Each operand above the bottom one combines with the result of the
//! operands below it by its own operation (`Node::bool_op`), or by the group's. Choosing an
//! operation with operands selected sets theirs; with one boolean group selected it sets the
//! group's (and every operand follows it again); otherwise the selection is wrapped in a new
//! boolean group ([`apply`]).
use figcraft_doc::{BoolOp, Document, NodeId, NodeKind};
use serde_json::{Value, json};
use crate::params::req_targets;
use crate::{Result, Session};
/// Whether `id` is a boolean group.
pub fn is_group(doc: &Document, id: NodeId) -> bool {
doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::BooleanOp { .. }))
}
/// The boolean group `id` is an operand of.
pub fn group_of(doc: &Document, id: NodeId) -> Option<NodeId> {
let parent = doc.node(id)?.parent?;
is_group(doc, parent).then_some(parent)
}
/// How operand `id` combines with the operands below it: its own operation or its group's
/// (`None` outside boolean groups).
pub fn operand_op(doc: &Document, id: NodeId) -> Option<BoolOp> {
let g = group_of(doc, id)?;
let NodeKind::BooleanOp { op } = doc.node(g)?.kind else { return None };
Some(doc.node(id)?.bool_op.unwrap_or(op))
}
/// An operand above the bottom one whose operation differs from its group's (what the layers
/// panel marks with the operation's icon).
pub fn distinct_op(doc: &Document, id: NodeId) -> Option<BoolOp> {
let g = doc.node(group_of(doc, id)?)?;
let NodeKind::BooleanOp { op } = g.kind else { return None };
let own = doc.node(id)?.bool_op.filter(|o| *o != op)?;
(g.children.first() != Some(&id)).then_some(own)
}
/// The operation the boolean controls show for `ids`: the group's for a lone boolean group, the
/// operands' when they share one (`None` otherwise).
pub fn shown_op(doc: &Document, ids: &[NodeId]) -> Option<BoolOp> {
if let [one] = ids
&& let Some(NodeKind::BooleanOp { op }) = doc.node(*one).map(|n| &n.kind)
{
return Some(*op);
}
let mut ops = ids.iter().map(|i| operand_op(doc, *i));
let first = ops.next()??;
ops.all(|o| o == Some(first)).then_some(first)
}
/// The operands to outline on the canvas while layers inside a boolean group are selected or
/// edited (`editing`: the layer in vector edit mode): every operand of the nearest boolean group
/// holding each of them, in layer order, without repeats.
pub fn outlined_operands(doc: &Document, selection: &[NodeId], editing: Option<NodeId>) -> Vec<NodeId> {
let mut out = Vec::new();
for id in selection.iter().copied().chain(editing) {
let Some(g) = doc.ancestors(id).into_iter().find(|a| is_group(doc, *a)) else { continue };
for c in doc.node(g).map(|n| n.children.clone()).unwrap_or_default() {
if doc.node(c).is_some_and(|n| n.visible) && !out.contains(&c) {
out.push(c);
}
}
}
out
}
/// `bool.union` / `bool.subtract` / `bool.intersect` / `bool.exclude`: see the module docs.
pub(crate) fn apply(s: &mut Session, cmd: &str, p: &Value, op: BoolOp) -> Result<Value> {
let ids = req_targets(cmd, s, p)?;
let doc = s.doc()?;
let lone_group = match ids.as_slice() {
[one] if is_group(doc, *one) => Some(*one),
_ => None,
};
if let Some(g) = lone_group {
let doc = s.doc_mut()?;
if let NodeKind::BooleanOp { op: o } = &mut doc.get_mut(g)?.kind {
*o = op;
}
doc.get_mut(g)?.name = renamed(&doc.get(g)?.name, op);
for c in doc.get(g)?.children.clone() {
if doc.get(c)?.bool_op.is_some() {
doc.get_mut(c)?.bool_op = None;
}
}
return Ok(json!({"id": g, "operation": op}));
}
if ids.iter().all(|i| group_of(doc, *i).is_some()) {
let doc = s.doc_mut()?;
for i in &ids {
let group_op = match group_of(doc, *i).and_then(|g| doc.node(g)).map(|g| &g.kind) {
Some(NodeKind::BooleanOp { op }) => *op,
_ => continue,
};
doc.get_mut(*i)?.bool_op = (op != group_op).then_some(op);
}
return Ok(json!({"ids": ids, "operation": op}));
}
let out = crate::cmd::structure::wrap_boolean(s, p, op)?;
// Operands of a new group follow its operation.
if let Some(g) = crate::params::id_of(&out["id"]) {
let doc = s.doc_mut()?;
for c in doc.get(g)?.children.clone() {
if doc.get(c)?.bool_op.is_some() {
doc.get_mut(c)?.bool_op = None;
}
}
}
Ok(out)
}
/// A boolean group's default name follows its operation; other names stay.
fn renamed(name: &str, op: BoolOp) -> String {
let default = [BoolOp::Union, BoolOp::Subtract, BoolOp::Intersect, BoolOp::Exclude].iter().any(|o| o.label() == name);
if default { op.label().to_string() } else { name.to_string() }
}
+215
View File
@@ -0,0 +1,215 @@
//! Live boolean editing: selecting operands on the canvas, results that follow an operand during a
//! drag or a point edit (one undo step each), per-operand operations from the boolean commands
//! and `node.set`, the operands outlined on the canvas, flatten, inspect and junk parameters.
use serde_json::{Value, json};
use crate::Session;
use crate::booleans::{distinct_op, operand_op, outlined_operands, shown_op};
use crate::params::id_of;
use crate::tools::{PointerEvent, PointerKind};
use figcraft_doc::{BoolOp, NodeId, NodeKind};
use figcraft_geom::Shape;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn ev(kind: PointerKind, x: f64, y: f64, cmd: bool) -> PointerEvent {
PointerEvent { cmd, ..PointerEvent::at(kind, x, y, 1.0) }
}
fn click(s: &mut Session, x: f64, y: f64, cmd: bool) {
s.pointer(&ev(PointerKind::Down, x, y, cmd)).unwrap();
s.pointer(&ev(PointerKind::Up, x, y, cmd)).unwrap();
}
/// A layer's filled area (a boolean group's result; none of these have holes).
fn area(s: &Session, id: NodeId) -> f64 {
let doc = s.doc().unwrap();
figcraft_render::node_outline(doc, doc.node(id).unwrap()).unwrap().0.area().abs()
}
/// A 100 × 100 square at the origin minus one at (50, 50): (group, bottom, top), nothing
/// selected, snapping off.
fn subtract(s: &mut Session) -> (NodeId, NodeId, NodeId) {
let a = id_of(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100}))["id"]).unwrap();
let b = id_of(&ex(s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100}))["id"]).unwrap();
let g = id_of(&ex(s, "bool.subtract", json!({"ids": [a, b]}))["id"]).unwrap();
ex(s, "select.none", json!({}));
let ds = s.ds_mut().unwrap();
ds.view.snap_objects = false;
ds.view.snap_pixel = false;
(g, a, b)
}
#[test]
fn clicks_select_the_group_and_reach_operands() {
let mut s = Session::new();
let (g, a, b) = subtract(&mut s);
assert!((area(&s, g) - 7500.0).abs() < 1.0);
click(&mut s, 20.0, 20.0, false);
assert_eq!(s.selection(), vec![g], "a click selects the group");
click(&mut s, 20.0, 20.0, true);
assert_eq!(s.selection(), vec![a], "Cmd-click selects the operand");
// With an operand selected, a click on another picks it.
click(&mut s, 120.0, 120.0, false);
assert_eq!(s.selection(), vec![b]);
// A double click goes into the group, then into vector edit mode on the operand.
ex(&mut s, "select.set", json!({"ids": [g]}));
s.pointer(&ev(PointerKind::DoubleClick, 120.0, 120.0, false)).unwrap();
assert_eq!(s.selection(), vec![b], "double click: the operand under the pointer");
s.pointer(&ev(PointerKind::DoubleClick, 120.0, 120.0, false)).unwrap();
let ve = s.ds().unwrap().vector_edit.clone().expect("vector edit on the operand");
assert_eq!(ve.id, b);
assert!(matches!(s.doc().unwrap().node(b).unwrap().kind, NodeKind::Vector { .. }));
assert_eq!(s.doc().unwrap().node(b).unwrap().parent, Some(g), "still an operand");
assert!((area(&s, g) - 7500.0).abs() < 1.0, "converting it changes nothing");
// The canvas outlines every operand of the group while one is edited.
assert_eq!(outlined_operands(s.doc().unwrap(), &s.selection(), Some(b)), vec![a, b]);
assert!(outlined_operands(s.doc().unwrap(), &[g], None).is_empty(), "not for the group itself");
}
#[test]
fn moving_an_operand_updates_the_result_during_the_drag() {
let mut s = Session::new();
let (g, a, _) = subtract(&mut s);
ex(&mut s, "select.set", json!({"ids": [a]}));
s.pointer(&ev(PointerKind::Down, 20.0, 20.0, false)).unwrap();
s.pointer(&ev(PointerKind::Move, 30.0, 20.0, false)).unwrap();
s.pointer(&ev(PointerKind::Move, 40.0, 20.0, false)).unwrap();
// Mid-drag: A spans 20..120, so the cut is 70 × 50.
assert!((area(&s, g) - 6500.0).abs() < 1.0, "live: {}", area(&s, g));
s.pointer(&ev(PointerKind::Up, 40.0, 20.0, false)).unwrap();
let doc = s.doc().unwrap();
assert!((area(&s, g) - 6500.0).abs() < 1.0, "after: {} {:?} {:?}", area(&s, g), doc.world_bounds(a), doc.node(g).unwrap().children);
// The group's box follows its operands.
let gb = s.doc().unwrap().world_bounds(g).unwrap();
assert!((gb.x0 - 20.0).abs() < 1e-6, "{gb:?}");
// One undo step for the drag.
s.undo().unwrap();
assert!((area(&s, g) - 7500.0).abs() < 1.0);
assert!((s.doc().unwrap().world_bounds(a).unwrap().x0).abs() < 1e-6);
}
#[test]
fn editing_an_operands_points_updates_the_result_live() {
let mut s = Session::new();
let (g, a, _) = subtract(&mut s);
ex(&mut s, "vector.edit.begin", json!({"id": a}));
// Drag A's top-left corner 50 to the left: A becomes a 12,500 trapezoid, the cut stays.
s.pointer(&ev(PointerKind::Down, 0.0, 0.0, false)).unwrap();
s.pointer(&ev(PointerKind::Move, -25.0, 0.0, false)).unwrap();
s.pointer(&ev(PointerKind::Move, -50.0, 0.0, false)).unwrap();
assert!((area(&s, g) - 10000.0).abs() < 1.0, "live: {}", area(&s, g));
s.pointer(&ev(PointerKind::Up, -50.0, 0.0, false)).unwrap();
assert!((area(&s, g) - 10000.0).abs() < 1.0);
s.undo().unwrap();
assert!((area(&s, g) - 7500.0).abs() < 1.0, "one undo step: {}", area(&s, g));
}
#[test]
fn boolean_commands_set_operand_and_group_operations() {
let mut s = Session::new();
let (g, a, b) = subtract(&mut s);
let c = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 200, "y": 0, "width": 50, "height": 50}))["id"]).unwrap();
ex(&mut s, "node.reparent", json!({"ids": [c], "parent": g}));
assert_eq!(s.doc().unwrap().node(c).unwrap().parent, Some(g));
// Subtracting the top operand removes it; uniting it adds its 2,500.
assert!((area(&s, g) - 7500.0).abs() < 1.0);
let r = ex(&mut s, "bool.union", json!({"ids": [c]}));
assert_eq!(r["operation"], json!("union"));
assert_eq!(operand_op(s.doc().unwrap(), c), Some(BoolOp::Union));
assert_eq!(distinct_op(s.doc().unwrap(), c), Some(BoolOp::Union));
assert!((area(&s, g) - 10000.0).abs() < 1.0, "{}", area(&s, g));
assert_eq!(s.ds().unwrap().doc.node(g).unwrap().children.len(), 3, "no new group");
// Undo is one step; the operand follows the group again when set to its operation.
s.undo().unwrap();
assert!((area(&s, g) - 7500.0).abs() < 1.0);
s.redo().unwrap();
ex(&mut s, "bool.subtract", json!({"ids": [c]}));
assert_eq!(s.doc().unwrap().node(c).unwrap().bool_op, None);
// The controls show the selection's operation.
ex(&mut s, "bool.intersect", json!({"ids": [b]}));
assert_eq!(shown_op(s.doc().unwrap(), &[b]), Some(BoolOp::Intersect));
assert_eq!(shown_op(s.doc().unwrap(), &[b, c]), None, "mixed");
assert_eq!(shown_op(s.doc().unwrap(), &[g]), Some(BoolOp::Subtract));
// (A ∩ B) − C: the 2,500 overlap.
assert!((area(&s, g) - 2500.0).abs() < 1.0, "{}", area(&s, g));
// One boolean group selected: its operation changes and every operand follows it.
ex(&mut s, "bool.union", json!({"ids": [g]}));
let doc = s.doc().unwrap();
assert!(matches!(doc.node(g).unwrap().kind, NodeKind::BooleanOp { op: BoolOp::Union }));
assert_eq!(doc.node(g).unwrap().name, "Union", "a default name follows the operation");
assert!([a, b, c].iter().all(|i| doc.node(*i).unwrap().bool_op.is_none()));
assert!((area(&s, g) - 20000.0).abs() < 1.0, "{}", area(&s, g));
// Anything else is wrapped in a new group, whose operands follow it.
let d = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 400, "y": 0, "width": 10, "height": 10}))["id"]).unwrap();
let r = ex(&mut s, "bool.exclude", json!({"ids": [g, d]}));
let outer = id_of(&r["id"]).unwrap();
assert_ne!(outer, g);
assert_eq!(s.doc().unwrap().node(g).unwrap().parent, Some(outer));
// Flatten bakes the mixed result.
ex(&mut s, "bool.subtract", json!({"ids": [c]}));
let before = area(&s, g);
let r = ex(&mut s, "bool.flatten", json!({"ids": [g]}));
let flat = id_of(&r["id"]).unwrap();
assert!((before - 17500.0).abs() < 1.0, "(A ∪ B) − C, C off to the side: {before}");
assert!((area(&s, flat) - before).abs() < 2.0, "{} vs {before}", area(&s, flat));
}
#[test]
fn node_set_and_inspect_carry_operand_operations() {
let mut s = Session::new();
let (g, a, b) = subtract(&mut s);
ex(&mut s, "node.set", json!({"id": b, "props": {"booleanOperation": "intersect"}}));
assert_eq!(s.doc().unwrap().node(b).unwrap().bool_op, Some(BoolOp::Intersect));
let n = ex(&mut s, "node.get", json!({"id": b}));
assert_eq!(n["operandOperation"], json!("intersect"));
ex(&mut s, "node.set", json!({"id": b, "props": {"operandOperation": null}}));
assert_eq!(s.doc().unwrap().node(b).unwrap().bool_op, None);
assert!(ex(&mut s, "node.get", json!({"id": b})).get("operandOperation").is_none());
// On the group, booleanOperation is its own.
ex(&mut s, "node.set", json!({"id": g, "props": {"booleanOperation": "exclude"}}));
assert!(matches!(s.doc().unwrap().node(g).unwrap().kind, NodeKind::BooleanOp { op: BoolOp::Exclude }));
// Errors: outside boolean groups, bad names.
let lone = id_of(&ex(&mut s, "shape.ellipse", json!({"x": 300, "y": 300, "width": 10, "height": 10}))["id"]).unwrap();
assert!(s.execute("node.set", &json!({"id": lone, "props": {"booleanOperation": "union"}})).is_err());
assert!(s.execute("node.set", &json!({"id": a, "props": {"operandOperation": "sideways"}})).is_err());
// Ungrouping releases the operands' own operations.
ex(&mut s, "node.set", json!({"id": b, "props": {"operandOperation": "union"}}));
ex(&mut s, "object.ungroup", json!({"ids": [g]}));
assert_eq!(s.doc().unwrap().node(b).unwrap().bool_op, None);
}
#[test]
fn boolean_commands_survive_junk() {
let junk = [
json!({}),
json!(null),
json!({"ids": "x"}),
json!({"ids": [999_999]}),
json!({"ids": [1e300, -1]}),
json!({"id": {"nested": true}}),
json!({"props": {"booleanOperation": [1, 2]}}),
];
for cmd in ["bool.union", "bool.subtract", "bool.intersect", "bool.exclude", "bool.flatten", "node.set"] {
for j in &junk {
let mut s = Session::new();
let (g, _, b) = subtract(&mut s);
ex(&mut s, "select.set", json!({"ids": [b]}));
if let Err(crate::EngineError::Internal { cmd, msg }) = s.execute(cmd, j) {
panic!("{cmd} panicked on {j}: {msg}");
}
if let Some(n) = s.doc().unwrap().node(g) {
let _ = figcraft_render::node_outline(s.doc().unwrap(), n);
}
}
}
// node.set with junk operations on an operand.
let mut s = Session::new();
let (_, _, b) = subtract(&mut s);
for v in [json!(7), json!({"op": "union"}), json!(""), json!(true)] {
assert!(s.execute("node.set", &json!({"id": b, "props": {"operandOperation": v}})).is_err(), "{v}");
}
}
+58 -12
View File
@@ -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);
+16 -4
View File
@@ -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;
+3
View File
@@ -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),
+2
View File
@@ -689,6 +689,8 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT
fresh.dev_changed = root.dev_changed;
fresh.dev_note = root.dev_note.clone();
fresh.constrain_proportions = root.constrain_proportions;
// How the instance combines in a boolean group it sits in is its own.
fresh.bool_op = root.bool_op;
// A swapped nested instance keeps the name its layer was given in the outer main.
let renamed = nested_copy
&& root.source.and_then(|s| doc.node(s)).is_some_and(|s| match &s.kind {
+5 -1
View File
@@ -53,7 +53,7 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
o.insert("dashes".into(), json!(n.stroke.dashes));
}
if let Some(p) = n.stroke.profile() {
let points: Vec<[f64; 2]> = p.points.iter().map(|q| [q.t, q.scale]).collect();
let points = crate::cmd::props::width_points_json(Some(p));
o.insert("widthProfile".into(), json!({"preset": p.preset().map(|x| x.id()), "points": points}));
}
}
@@ -145,6 +145,10 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
if let Some(slot) = crate::components::slot_name(doc, n.id) {
o.insert("slot".into(), json!(slot));
}
// An operand combining by an operation of its own.
if let Some(op) = n.bool_op.filter(|_| crate::booleans::group_of(doc, n.id).is_some()) {
o.insert("operandOperation".into(), json!(op));
}
}
match &n.kind {
NodeKind::Text(t) => {
+7
View File
@@ -10,6 +10,7 @@
pub mod al_keys;
pub mod annotations;
pub mod arc_handles;
pub mod booleans;
pub mod catalog;
pub mod cmd;
pub mod codegen;
@@ -1123,12 +1124,16 @@ mod annotation_categories_tests;
#[cfg(test)]
mod annotations_tests;
#[cfg(test)]
mod arc_fields_tests;
#[cfg(test)]
mod arc_handles_tests;
#[cfg(test)]
mod autosave_tests;
#[cfg(test)]
mod binding_lists_tests;
#[cfg(test)]
mod booleans_tests;
#[cfg(test)]
mod collab_ids_tests;
#[cfg(test)]
mod collab_live_tests;
@@ -1215,6 +1220,8 @@ mod section_tests;
#[cfg(test)]
mod selection_depth_tests;
#[cfg(test)]
mod shapebuilder_strokes_tests;
#[cfg(test)]
mod shapebuilder_tests;
#[cfg(test)]
mod slot_tests;
+119 -34
View File
@@ -6,6 +6,13 @@
//! way every shape covering a picked region loses that area (it becomes a vector, or is deleted
//! when nothing is left), so the rest of the drawing stays exactly where it was.
//!
//! **Strokes and open paths.** A visible stroke takes part as its outlined area (a [`Part`] of its
//! own, above the shape's fill), so stroked lines and open paths split the regions they cross and
//! the band a stroke paints is a region too. When a stroked shape takes part in a merge or a
//! removal, its stroke becomes an outlined vector (filled with the stroke's paints) just above it,
//! so both keep looking the same; open paths, which have no fill, are replaced by that outline. A
//! region merged from a stroke is filled with the stroke's paints.
//!
//! With the Shape builder tool (Shift+M) the canvas highlights the region under the pointer; a
//! click or a drag across regions merges them, and Alt (or the tool's Remove mode) removes them.
//! Each gesture is one command, so one undo step. The arrangement is cached per document version
@@ -25,6 +32,13 @@ use crate::params::*;
use crate::tools::{Cursor, Gesture, PointerEvent, PointerKind, ToolId};
use crate::{EngineError, Result, Session, bad, spec};
/// One input of the arrangement: a shape's fill area, or its stroke's outlined area.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
pub struct Part {
pub id: NodeId,
pub stroke: bool,
}
/// One region of the arrangement, in page coordinates.
#[derive(Clone, Debug)]
pub struct Region {
@@ -32,6 +46,8 @@ pub struct Region {
pub path: BezPath,
/// The shapes covering it, top-most first.
pub sources: Vec<NodeId>,
/// The fills and strokes covering it, top-most first.
pub parts: Vec<Part>,
pub bounds: Rect,
/// The outline flattened to polygons (outer first), for drawing.
pub polys: Vec<Vec<Point>>,
@@ -47,6 +63,8 @@ pub struct Arrangement {
ids: Vec<NodeId>,
/// The shapes taking part, bottom to top.
pub sources: Vec<NodeId>,
/// Their fills and strokes, bottom to top (a shape's stroke above its fill).
pub parts: Vec<Part>,
pub regions: Vec<Region>,
}
@@ -64,11 +82,11 @@ impl Arrangement {
}
}
/// Can `id` take part? Visible, unlocked shapes, boolean groups and text with an area, outside
/// instances and boolean groups (whose structure is fixed).
/// Can `id` take part? Visible, unlocked shapes (lines and open paths through their strokes),
/// boolean groups and text, outside instances and boolean groups (whose structure is fixed).
fn usable(doc: &Document, id: NodeId) -> bool {
let Some(n) = doc.node(id) else { return false };
let kind_ok = (n.kind.is_shape() && !matches!(n.kind, NodeKind::Line)) || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_));
let kind_ok = n.kind.is_shape() || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_));
kind_ok
&& n.visible
&& !n.locked
@@ -98,11 +116,19 @@ pub fn eligible(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
out
}
/// A shape's fill area in page coordinates and whether it fills even-odd.
fn world_outline(doc: &Document, id: NodeId) -> Option<(BezPath, bool)> {
let n = doc.node(id)?;
let (mut p, rule) = figcraft_render::node_outline(doc, n)?;
p.apply_affine(doc.world_transform(id));
/// A part's area in page coordinates and whether it fills even-odd: the shape's fill area (closed
/// shapes, boolean groups and text) or its visible stroke's outlined area.
fn world_area(doc: &Document, part: Part) -> Option<(BezPath, bool)> {
let n = doc.node(part.id)?;
let (mut p, rule) = if part.stroke {
figcraft_render::stroke_fill(doc, n).map(|(p, rule)| ((*p).clone(), rule))?
} else {
if !(figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_))) {
return None;
}
figcraft_render::node_outline(doc, n)?
};
p.apply_affine(doc.world_transform(part.id));
(!p.elements().is_empty() && figcraft_pathops::finite(&p)).then_some((p, rule == figcraft_render::FillRule::EvenOdd))
}
@@ -125,11 +151,20 @@ fn polygons(path: &BezPath, bounds: Rect) -> Vec<Vec<Point>> {
fn compute(doc: Arc<Document>, uid: u64, ids: &[NodeId]) -> Result<Arrangement> {
let mut sources = Vec::new();
let mut parts = Vec::new();
let mut inputs = Vec::new();
for id in eligible(&doc, ids) {
if let Some(o) = world_outline(&doc, id) {
let before = parts.len();
for part in [Part { id, stroke: false }, Part { id, stroke: true }] {
if inputs.len() < figcraft_pathops::MAX_ARRANGEMENT_INPUTS
&& let Some(o) = world_area(&doc, part)
{
parts.push(part);
inputs.push(o);
}
}
if parts.len() > before {
sources.push(id);
inputs.push(o);
}
}
let faces = figcraft_pathops::faces(&inputs).map_err(|e| EngineError::Other(format!("shape builder: {e}")))?;
@@ -138,8 +173,16 @@ fn compute(doc: Arc<Document>, uid: u64, ids: &[NodeId]) -> Result<Arrangement>
.filter(|f| f.path.area().abs() > 1e-4)
.map(|f| {
let bounds = f.path.bounding_box();
let covering: Vec<Part> = f.cover.iter().rev().filter_map(|i| parts.get(*i).copied()).collect();
let mut by: Vec<NodeId> = Vec::new();
for p in &covering {
if !by.contains(&p.id) {
by.push(p.id);
}
}
Region {
sources: f.cover.iter().rev().filter_map(|i| sources.get(*i).copied()).collect(),
sources: by,
parts: covering,
polys: polygons(&f.path, bounds),
point: figcraft_doc::vector_faces::interior_point(&f.path),
bounds,
@@ -149,7 +192,7 @@ fn compute(doc: Arc<Document>, uid: u64, ids: &[NodeId]) -> Result<Arrangement>
.collect();
// Reading order: top to bottom, then left to right.
regions.sort_by(|a, b| a.bounds.y0.total_cmp(&b.bounds.y0).then(a.bounds.x0.total_cmp(&b.bounds.x0)));
Ok(Arrangement { doc, uid, ids: ids.to_vec(), sources, regions })
Ok(Arrangement { doc, uid, ids: ids.to_vec(), sources, parts, regions })
}
/// The arrangement of `ids` (default: the selection), from the cache when the document and the
@@ -230,9 +273,16 @@ fn reshape(doc: &mut Document, id: NodeId, area: &BezPath) -> Result<()> {
crate::vectoredit::refit(doc, id)
}
/// A new vector for the page-space area `area`, styled like `style`, just above it.
fn create_merged(doc: &mut Document, style: NodeId, area: &BezPath) -> Result<NodeId> {
let src = doc.get(style)?.clone();
/// A new vector for the page-space area `area`, styled like part `style` (a stroke part: filled
/// with the stroke's paints), just above its shape.
fn create_merged(doc: &mut Document, style: Part, area: &BezPath) -> Result<NodeId> {
let mut src = doc.get(style.id)?.clone();
if style.stroke {
src.fills = std::mem::take(&mut src.strokes);
src.stroke = StrokeProps::default();
src.styles.fill = src.styles.stroke.take();
}
let style = style.id;
let parent = src.parent.ok_or_else(|| EngineError::Other("no parent".into()))?;
let pw = doc.world_transform(parent);
if !(pw.determinant().is_finite() && pw.determinant().abs() > 1e-12) {
@@ -266,7 +316,8 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
return Err(bad(cmd, "no region picked: pass `regions` (indexes from shapeBuilder.regions) or `points` inside regions"));
}
let regions: Vec<&Region> = pick.iter().filter_map(|i| a.regions.get(*i)).collect();
let touched: BTreeSet<NodeId> = regions.iter().flat_map(|r| r.sources.iter().copied()).collect();
let touched_parts: BTreeSet<Part> = regions.iter().flat_map(|r| r.parts.iter().copied()).collect();
let touched: BTreeSet<NodeId> = touched_parts.iter().map(|p| p.id).collect();
// Merging every region of a single shape would only replace it with a copy of itself.
let whole_shape =
touched.len() == 1 && a.regions.iter().enumerate().all(|(i, r)| pick.contains(&i) || !r.sources.iter().any(|x| touched.contains(x)));
@@ -276,40 +327,73 @@ fn apply(s: &mut Session, cmd: &str, p: &Value, merge: bool) -> Result<Value> {
let doc = s.doc_mut()?;
let created = if merge {
let area = figcraft_pathops::union_faces(regions.iter().map(|r| &r.path)).map_err(|e| EngineError::Other(e.to_string()))?;
let style = regions.first().and_then(|r| r.sources.first().copied()).ok_or_else(|| bad(cmd, "the region has no shape"))?;
let style = regions.first().and_then(|r| r.parts.first().copied()).ok_or_else(|| bad(cmd, "the region has no shape"))?;
Some(create_merged(doc, style, &area)?)
} else {
None
};
let mut removed = Vec::new();
let mut changed = Vec::new();
for src in a.sources.iter().copied().filter(|i| touched.contains(i)) {
// What part `part` keeps: the regions it covers that weren't picked, as one area (`None`:
// nothing).
let kept = |part: Part| -> Result<Option<BezPath>> {
let keep: Vec<&BezPath> =
a.regions.iter().enumerate().filter(|(i, r)| !pick.contains(i) && r.sources.contains(&src)).map(|(_, r)| &r.path).collect();
a.regions.iter().enumerate().filter(|(i, r)| !pick.contains(i) && r.parts.contains(&part)).map(|(_, r)| &r.path).collect();
if keep.is_empty() {
doc.remove(src)?;
removed.push(src);
continue;
return Ok(None);
}
let area = figcraft_pathops::union_faces(keep).map_err(|e| EngineError::Other(e.to_string()))?;
if area.elements().is_empty() {
doc.remove(src)?;
removed.push(src);
Ok((!area.elements().is_empty()).then_some(area))
};
let mut removed = Vec::new();
let mut changed = Vec::new();
let mut outlined: Vec<(NodeId, NodeId)> = Vec::new();
for src in a.sources.iter().copied().filter(|i| touched.contains(i)) {
let (fill, stroke) = (Part { id: src, stroke: false }, Part { id: src, stroke: true });
// The stroke becomes an outline of its own (what's left of it), the shape loses it.
if a.parts.contains(&stroke) {
if let Some(area) = kept(stroke)? {
outlined.push((src, create_merged(doc, stroke, &area)?));
}
let n = doc.get_mut(src)?;
n.strokes.clear();
n.styles.stroke = None;
}
let left = if !a.parts.contains(&fill) {
None
} else if touched_parts.contains(&fill) {
kept(fill)?
} else {
reshape(doc, src, &area)?;
// The fill wasn't picked: the shape stays as it is.
changed.push(src);
continue;
};
match left {
Some(area) => {
reshape(doc, src, &area)?;
changed.push(src);
}
None => {
doc.remove(src)?;
removed.push(src);
}
}
}
// The selection stays (minus what went) and gains the new shape unless a selected group
// already holds it, so the tool keeps working on the same shapes.
// The selection stays (minus what went, plus the outlines of selected strokes) and gains the
// new shape unless a selected group already holds it, so the tool keeps working on the same
// shapes.
let mut sel: Vec<NodeId> = ids.iter().copied().filter(|i| !removed.contains(i)).collect();
for (src, o) in &outlined {
if ids.contains(src) {
sel.push(*o);
}
}
if let Some(c) = created
&& !doc.ancestors(c).iter().any(|x| sel.contains(x))
{
sel.push(c);
}
s.set_selection(sel);
Ok(json!({"id": created, "changed": changed, "removed": removed, "regions": pick}))
let outlines: Vec<NodeId> = outlined.iter().map(|(_, o)| *o).collect();
Ok(json!({"id": created, "changed": changed, "removed": removed, "outlines": outlines, "regions": pick}))
}
pub fn specs() -> Vec<CommandSpec> {
@@ -317,7 +401,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"shapeBuilder.regions",
"Shape builder regions",
"{ids?} → {sources (bottom to top), regions: [{index, path (SVG, page), sources (top-most first), bounds [x, y, w, h], point [x, y] inside}]}",
"{ids?} → {sources (bottom to top), regions: [{index, path (SVG, page), sources (top-most first), parts: [{id, stroke}] (the fills and outlined strokes covering it, top-most first), bounds [x, y, w, h], point [x, y] inside}]} — visible strokes take part as their outlined areas, so stroked lines and open paths split regions",
false,
|s, p| {
let ids = targets(s, p);
@@ -331,6 +415,7 @@ pub fn specs() -> Vec<CommandSpec> {
"index": i,
"path": r.path.to_svg(),
"sources": r.sources,
"parts": r.parts,
"bounds": [r.bounds.x0, r.bounds.y0, r.bounds.width(), r.bounds.height()],
"point": r.point.map(|q| [q.x, q.y]),
})
@@ -342,14 +427,14 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"shapeBuilder.merge",
"Merge regions",
"{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} — one new shape from the regions, styled like the top-most shape over the first; the shapes lose those areas",
"{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} → {id, changed, removed, outlines, regions} — one new shape from the regions, styled like the top-most fill or stroke over the first; the shapes lose those areas (strokes of the shapes involved become outlined vectors: `outlines`)",
true,
|s, p| apply(s, "shapeBuilder.merge", p, true)
),
spec!(
"shapeBuilder.remove",
"Remove regions",
"{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} — the shapes covering the regions lose them",
"{ids?, regions?: [i, ...], points?: [[x, y], ...] (page)} → {changed, removed, outlines, regions} — the shapes (and strokes) covering the regions lose them",
true,
|s, p| apply(s, "shapeBuilder.remove", p, false)
),
@@ -0,0 +1,156 @@
//! The shape builder with strokes and open paths: strokes take part as their outlined areas, open
//! paths split the regions they cross, merging a stroke's region fills it with the stroke's paints,
//! and the strokes of the shapes involved become outlined vectors.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use figcraft_doc::{NodeId, NodeKind};
use figcraft_geom::Shape;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn id(v: &Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
/// A layer's filled area in page units.
fn area(s: &Session, id: NodeId) -> f64 {
let doc = s.doc().unwrap();
let (mut p, _) = figcraft_render::node_outline(doc, doc.node(id).unwrap()).unwrap();
p.apply_affine(doc.world_transform(id));
p.area().abs()
}
fn fill_hex(s: &Session, id: NodeId) -> String {
s.doc().unwrap().node(id).unwrap().fills[0].solid_color().unwrap().to_hex()
}
/// A red 100 × 100 square and, above it, a black 10 wide open path across its middle from x = -20
/// to 120; both selected.
fn square_and_path(s: &mut Session) -> (NodeId, NodeId) {
let r = id(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": {"fill": "#ff0000"}})));
let l = id(&ex(s, "vector.create", json!({"points": [[-20, 50], [120, 50]], "closed": false})));
ex(s, "node.set", json!({"id": l, "props": {"strokes": ["#000000"], "strokeWeight": 10, "strokeAlign": "center"}}));
s.set_selection(vec![r, l]);
(r, l)
}
#[test]
fn a_stroked_open_path_splits_the_regions_it_crosses() {
let mut s = Session::new();
let (r, l) = square_and_path(&mut s);
let out = ex(&mut s, "shapeBuilder.regions", json!({}));
assert_eq!(out["sources"], json!([r, l]));
let regions = out["regions"].as_array().unwrap();
// Above and below the path, the band across the square, and the path's two ends outside it.
assert_eq!(regions.len(), 5, "{out}");
let covered = |x: f64, y: f64| -> Value {
regions
.iter()
.find(|g| {
let b = &g["bounds"];
let (x0, y0) = (b[0].as_f64().unwrap(), b[1].as_f64().unwrap());
x >= x0 && y >= y0 && x <= x0 + b[2].as_f64().unwrap() && y <= y0 + b[3].as_f64().unwrap()
})
.map(|g| g["parts"].clone())
.unwrap_or(Value::Null)
};
assert_eq!(covered(50.0, 20.0), json!([{"id": r, "stroke": false}]));
assert_eq!(covered(50.0, 50.0), json!([{"id": l, "stroke": true}, {"id": r, "stroke": false}]), "the stroke is above the fill");
assert_eq!(covered(-10.0, 50.0), json!([{"id": l, "stroke": true}]));
// A path without a visible stroke paints nothing and takes no part.
ex(&mut s, "node.set", json!({"id": l, "props": {"strokes": []}}));
let out = ex(&mut s, "shapeBuilder.regions", json!({}));
assert_eq!(out["sources"], json!([r]));
assert_eq!(out["regions"].as_array().unwrap().len(), 1);
}
#[test]
fn merging_a_stroke_region_fills_it_with_the_stroke() {
let mut s = Session::new();
let (r, l) = square_and_path(&mut s);
let out = ex(&mut s, "shapeBuilder.merge", json!({"points": [[50, 50]]}));
let m = id(&out);
// The band, black (the stroke's paint), no stroke of its own.
assert!((area(&s, m) - 1000.0).abs() < 1.0, "{}", area(&s, m));
assert_eq!(fill_hex(&s, m), "000000");
assert!(s.doc().unwrap().node(m).unwrap().strokes.is_empty());
// The square lost the band; the path (no fill) became an outline of what was left of its
// stroke: the two ends outside the square.
assert!((area(&s, r) - 9000.0).abs() < 1.0, "{}", area(&s, r));
assert_eq!(out["removed"], json!([l]));
let o = id_of(&out["outlines"][0]).unwrap();
assert!((area(&s, o) - 400.0).abs() < 1.0, "{}", area(&s, o));
assert_eq!(fill_hex(&s, o), "000000");
assert!(matches!(s.doc().unwrap().node(o).unwrap().kind, NodeKind::Vector { .. }));
// The selection keeps working: the square, the outline and the new shape.
let sel = s.selection();
assert!(sel.contains(&r) && sel.contains(&o) && sel.contains(&m), "{sel:?}");
// One undo step.
s.undo().unwrap();
let doc = s.doc().unwrap();
assert!(doc.contains(l) && !doc.contains(m) && !doc.contains(o));
assert!((area(&s, r) - 10000.0).abs() < 1.0);
}
#[test]
fn removing_regions_spares_strokes_that_arent_involved() {
let mut s = Session::new();
let (r, l) = square_and_path(&mut s);
let out = ex(&mut s, "shapeBuilder.remove", json!({"points": [[50, 20]]}));
// The square keeps the band and the part below; the path isn't touched.
assert!((area(&s, r) - 5500.0).abs() < 1.0, "{}", area(&s, r));
assert_eq!(out["outlines"], json!([]));
let n = s.doc().unwrap().node(l).unwrap();
assert!(!n.strokes.is_empty() && matches!(n.kind, NodeKind::Vector { .. }));
// Removing an end of the path leaves the rest of its stroke as an outline.
let out = ex(&mut s, "shapeBuilder.remove", json!({"ids": [r, l], "points": [[-10, 50]]}));
assert_eq!(out["removed"], json!([l]));
let o = id_of(&out["outlines"][0]).unwrap();
assert!((area(&s, o) - (1000.0 + 200.0)).abs() < 1.0, "the band and the other end: {}", area(&s, o));
}
#[test]
fn a_stroked_shape_gives_its_ring_and_keeps_its_look() {
let mut s = Session::new();
// An inside stroke 10 wide: a ring inside the square's edge.
let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100, "props": {"fill": "#ff0000"}})));
ex(&mut s, "node.set", json!({"id": r, "props": {"strokes": ["#0000ff"], "strokeWeight": 10, "strokeAlign": "inside"}}));
s.set_selection(vec![r]);
let out = ex(&mut s, "shapeBuilder.regions", json!({}));
assert_eq!(out["regions"].as_array().unwrap().len(), 2, "the middle and the ring: {out}");
// Removing the middle leaves the fill under the ring and the ring as a blue outline above it.
let out = ex(&mut s, "shapeBuilder.remove", json!({"points": [[50, 50]]}));
assert_eq!(out["changed"], json!([r]));
assert!((area(&s, r) - 3600.0).abs() < 1.0, "{}", area(&s, r));
assert!(s.doc().unwrap().node(r).unwrap().strokes.is_empty());
let o = id_of(&out["outlines"][0]).unwrap();
assert!((area(&s, o) - 3600.0).abs() < 1.0);
assert_eq!(fill_hex(&s, o), "0000FF");
let doc = s.doc().unwrap();
let parent = doc.node(r).unwrap().parent.unwrap();
let kids = &doc.node(parent).unwrap().children;
assert!(kids.iter().position(|k| *k == o) > kids.iter().position(|k| *k == r), "the outline sits above the shape");
}
#[test]
fn stroke_regions_survive_junk() {
for j in [
json!({"points": [[f64::MAX, 0]]}),
json!({"regions": [-1, 1e20, "x"]}),
json!({"ids": [123_456], "points": [[50, 50]]}),
json!({"points": "x"}),
] {
for cmd in ["shapeBuilder.regions", "shapeBuilder.merge", "shapeBuilder.remove"] {
let mut s = Session::new();
let _ = square_and_path(&mut s);
if let Err(crate::EngineError::Internal { cmd, msg }) = s.execute(cmd, &j) {
panic!("{cmd} panicked on {j}: {msg}");
}
}
}
}
+1 -1
View File
@@ -225,7 +225,7 @@ fn groups_rotated_and_ineligible_layers() {
let line = id(&ex(&mut s, "shape.line", json!({"x": 0, "y": 5, "width": 20})));
let frame = id(&ex(&mut s, "frame.create", json!({"x": 100, "y": 100, "width": 50, "height": 50})));
let r = ex(&mut s, "shapeBuilder.regions", json!({"ids": [g, line, frame]}));
assert_eq!(r["sources"], json!([a, b]), "groups give their shapes; lines and frames don't take part");
assert_eq!(r["sources"], json!([a, b, line]), "groups give their shapes; lines take part through their strokes; frames don't");
// The merged shape lands in the group, which stays selected.
s.set_selection(vec![g]);
let m = id(&ex(&mut s, "shapeBuilder.merge", json!({"points": [[7, 7]]})));
+9 -7
View File
@@ -1016,9 +1016,10 @@ impl Session {
let p = ev.p();
let home = moving.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent);
// Absolute children of an auto layout frame stay in it, and so do the layers of
// an instance (outside its slots: they only move); the rest may change parent.
// an instance (outside its slots: they only move) and the operands of a boolean
// group (its result follows them); the rest may change parent.
let flow = moving.iter().all(|i| doc.node(*i).is_some_and(|n| !n.child_layout.absolute))
&& !home.is_some_and(|h| crate::components::structure_locked(doc, h));
&& !home.is_some_and(|h| crate::components::structure_locked(doc, h) || crate::booleans::is_group(doc, h));
let target = if flow { crate::drop::drag_parent(doc, page, p, &moving) } else { home.unwrap_or(page) };
let mode = doc.node(target).and_then(|n| n.layout.as_ref()).map(|l| l.mode);
if let (true, Some(mode)) = (flow, mode) {
@@ -1410,9 +1411,11 @@ impl Session {
let before = ds.doc.clone();
let d = ds.doc_mut();
for m in &moving {
// Instance layers stay put (outside slots): they only move.
// Instance layers (outside slots) and boolean operands stay put: they only move.
let from = d.node(*m).and_then(|n| n.parent);
if from.is_some_and(|f| crate::components::structure_locked(d, f)) || crate::components::structure_locked(d, target) {
if from.is_some_and(|f| crate::components::structure_locked(d, f) || crate::booleans::is_group(d, f))
|| crate::components::structure_locked(d, target)
{
continue;
}
// A layer can't go into itself; it stays where it is.
@@ -1506,9 +1509,8 @@ impl Session {
if doc.node(target).is_some_and(|n| n.kind.is_shape()) && sel.contains(&target) {
return crate::vectoredit::begin(self, target);
}
// Groups/frames: select one level deeper (the child under the pointer).
let deeper =
select_target(&doc, leaf, &[select_target(&doc, leaf, &sel, false)].into_iter().chain(sel.iter().copied()).collect::<Vec<_>>(), false);
// Groups, frames and boolean groups: select one level deeper (the layer under the pointer).
let deeper = figcraft_doc::geometry::enter_target(&doc, leaf, &sel);
if deeper != target || !sel.contains(&deeper) {
self.set_selection(vec![deeper]);
}
+2
View File
@@ -16,6 +16,8 @@ pub mod transform;
mod transform_tests;
mod width;
#[cfg(test)]
mod width_sides_tests;
#[cfg(test)]
mod width_tests;
use figcraft_doc::*;
+105 -37
View File
@@ -3,6 +3,10 @@
//! dragging a point slides it along the path, dragging one of its handles changes its width;
//! Delete removes the selected point. Every gesture is one undo step.
//!
//! **One-sided widths:** with Alt held, dragging a handle (or the path) changes the width on that
//! side of the path only; a plain handle drag makes both sides as wide again. The sides are left
//! and right looking along the path (see [`WidthPoint`]).
//!
//! The points live in the stroke's [`WidthProfile`] and apply to every subpath (positions are
//! fractions of each subpath's length).
@@ -29,10 +33,11 @@ const TOL_PX: f64 = 6.0;
pub enum WHit {
/// A width point on the path (drag slides it along).
Point { index: usize },
/// One of a width point's handles (drag sets its width).
Handle { index: usize },
/// The path between points (drag adds a point there); `t` is the position along it.
Path { t: f64 },
/// One of a width point's handles (drag sets its width; with Alt, that side's only).
Handle { index: usize, side: WidthSide },
/// The path between points (drag adds a point there); `t` is the position along it and `side`
/// the side of the path the pointer is on.
Path { t: f64, side: WidthSide },
}
/// A width point as drawn: on the path and its two handles, in page coordinates, per subpath.
@@ -41,9 +46,12 @@ pub enum WHit {
pub struct WidthHandle {
pub index: usize,
pub at: [f64; 2],
/// The handles on the right and the left of the path.
pub sides: [[f64; 2]; 2],
/// The stroke's full width there (local units).
pub width: f64,
/// Whether the two sides differ.
pub one_sided: bool,
}
/// An in-progress Variable width gesture.
@@ -52,7 +60,7 @@ pub(crate) enum WidthGesture {
/// Slide point `index` along the path.
Slide { index: usize, moved: bool },
/// Set the width of point `index`; `added` = the gesture added it at `t`.
Size { index: usize, added: Option<f64>, moved: bool },
Size { index: usize, added: Option<f64>, moved: bool, side: WidthSide },
}
/// The edited node's stroke path flattened (local coordinates) with its weight and profile.
@@ -68,24 +76,31 @@ fn stroked(doc: &Document, id: NodeId) -> Option<Stroked> {
Some(Stroked { lines: polylines(&path)?, weight: n.stroke.weight, profile: n.stroke.profile().cloned() })
}
/// Width handles for `(index, t, scale)` on every subpath, as drawn at `zoom` (page coordinates).
fn handles(doc: &Document, id: NodeId, st: &Stroked, pts: impl Iterator<Item = (usize, f64, f64)> + Clone, zoom: f64) -> Vec<WidthHandle> {
/// Width handles for `(index, t, (left, right) side scales)` on every subpath, as drawn at `zoom`
/// (page coordinates).
fn handles(doc: &Document, id: NodeId, st: &Stroked, pts: impl Iterator<Item = (usize, f64, (f64, f64))> + Clone, zoom: f64) -> Vec<WidthHandle> {
let w = doc.world_transform(id);
let min = HANDLE_MIN_PX / zoom.max(1e-6);
let mut out = Vec::new();
for line in &st.lines {
for (index, t, scale) in pts.clone() {
for (index, t, (left, right)) in pts.clone() {
let Some((at, n)) = line.at(t) else { continue };
let half = 0.5 * st.weight * scale;
let (hl, hr) = (0.5 * st.weight * left, 0.5 * st.weight * right);
let c = w * at;
let dir = w * (at + n) - c;
let dir = if dir.hypot() > 1e-12 { dir / dir.hypot() } else { Vec2::new(0.0, 1.0) };
let reach = ((w * (at + n * half)) - c).hypot().max(min);
let side = |s: f64| {
let side = |half: f64, s: f64| {
let reach = ((w * (at + n * half)) - c).hypot().max(min);
let q = c + dir * (reach * s);
[q.x, q.y]
};
out.push(WidthHandle { index, at: [c.x, c.y], sides: [side(1.0), side(-1.0)], width: 2.0 * half });
out.push(WidthHandle {
index,
at: [c.x, c.y],
sides: [side(hr, 1.0), side(hl, -1.0)],
width: hl + hr,
one_sided: (left - right).abs() > 1e-9,
});
}
}
out
@@ -95,14 +110,14 @@ fn handles(doc: &Document, id: NodeId, st: &Stroked, pts: impl Iterator<Item = (
pub fn width_handles(doc: &Document, ve: &super::VectorEdit, zoom: f64) -> Vec<WidthHandle> {
let Some(st) = stroked(doc, ve.id) else { return vec![] };
let Some(profile) = st.profile.clone() else { return vec![] };
handles(doc, ve.id, &st, profile.points.iter().enumerate().map(|(i, p)| (i, p.t, p.scale)), zoom)
handles(doc, ve.id, &st, profile.points.iter().enumerate().map(|(i, p)| (i, p.t, p.sides())), zoom)
}
/// Where a width point added at `t` would go, with the width there (the hover preview).
pub fn width_preview(doc: &Document, ve: &super::VectorEdit, t: f64, zoom: f64) -> Vec<WidthHandle> {
let Some(st) = stroked(doc, ve.id) else { return vec![] };
let scale = st.profile.as_ref().map_or(1.0, |p| p.scale_at(t));
handles(doc, ve.id, &st, std::iter::once((usize::MAX, t, scale)), zoom)
let sides = st.profile.as_ref().map_or((1.0, 1.0), |p| p.sides_at(t));
handles(doc, ve.id, &st, std::iter::once((usize::MAX, t, sides)), zoom)
}
/// Hit test the Variable width tool at page point `p`: handles, then points, then the path.
@@ -114,7 +129,10 @@ pub fn width_hit(doc: &Document, ve: &super::VectorEdit, p: Point, zoom: f64) ->
handles.iter().map(|h| (h.index, pick(h))).filter(|(_, d)| *d <= tol).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i)
};
if let Some(index) = best(&|h| near(h.sides[0]).min(near(h.sides[1]))) {
return Some(WHit::Handle { index });
// The nearer of its handles (across subpaths).
let d = |k: usize| handles.iter().filter(|h| h.index == index).map(|h| near(h.sides[k])).fold(f64::INFINITY, f64::min);
let side = if d(0) <= d(1) { WidthSide::Right } else { WidthSide::Left };
return Some(WHit::Handle { index, side });
}
if let Some(index) = best(&|h| near(h.at)) {
return Some(WHit::Point { index });
@@ -122,15 +140,21 @@ pub fn width_hit(doc: &Document, ve: &super::VectorEdit, p: Point, zoom: f64) ->
// Anywhere on the painted stroke counts, or near its centre line when it's thin.
let st = stroked(doc, ve.id)?;
let w = doc.world_transform(ve.id);
let (sub, t, _) = nearest(&st.lines, w.inverse() * p)?;
let (q, _) = st.lines.get(sub)?.at(t)?;
let half = 0.5 * st.weight * st.profile.as_ref().map_or(1.0, |pr| pr.scale_at(t)) * w.determinant().abs().sqrt();
((w * q - p).hypot() <= tol.max(half)).then_some(WHit::Path { t })
let lp = w.inverse() * p;
let (sub, t, _) = nearest(&st.lines, lp)?;
let (q, n) = st.lines.get(sub)?.at(t)?;
let side = if (lp - q).dot(n) >= 0.0 { WidthSide::Right } else { WidthSide::Left };
let scale = st.profile.as_ref().map_or(1.0, |pr| {
let (l, r) = pr.sides_at(t);
if side == WidthSide::Right { r } else { l }
});
let half = 0.5 * st.weight * scale * w.determinant().abs().sqrt();
((w * q - p).hypot() <= tol.max(half)).then_some(WHit::Path { t, side })
}
/// Write node `id`'s width profile (through `node.set`'s key, so instances record the override).
fn put_profile(doc: &mut Document, id: NodeId, profile: Option<&WidthProfile>) -> Result<()> {
let v = profile.filter(|p| !p.points.is_empty()).map_or(Value::Null, |p| json!(p.points.iter().map(|q| [q.t, q.scale]).collect::<Vec<_>>()));
let v = crate::cmd::props::width_points_json(profile);
crate::cmd::props::apply("vector.edit.widthPoint", doc, id, &serde_json::Map::from_iter([("widthProfile".to_string(), v)]))
}
@@ -139,10 +163,25 @@ fn profile_of(doc: &Document, id: NodeId) -> WidthProfile {
doc.node(id).and_then(|n| n.stroke.profile()).cloned().unwrap_or_else(WidthProfile::uniform)
}
/// Local point `lp` seen from point `t` of the path (the nearest subpath's point there): how far
/// out it is along the normal (negative to the left) and the side it is on.
fn pointer_at(st: &Stroked, t: f64, lp: Point) -> Option<(f64, WidthSide)> {
let (q, n) = st.lines.iter().filter_map(|l| l.at(t)).min_by(|a, b| (a.0 - lp).hypot().total_cmp(&(b.0 - lp).hypot()))?;
let along = (lp - q).dot(n);
Some((along, if along >= 0.0 { WidthSide::Right } else { WidthSide::Left }))
}
/// The width scale for a handle dragged to local point `lp` from point `t` (the nearest
/// subpath's point there counts).
fn scale_at_pointer(st: &Stroked, t: f64, lp: Point) -> Option<f64> {
let d = st.lines.iter().filter_map(|l| l.at(t)).map(|(q, _)| (q - lp).hypot()).fold(f64::INFINITY, f64::min);
/// subpath's point there counts). With `side`, the scale of that side only: how far the pointer
/// is out to that side (0 past the centre line).
fn scale_at_pointer(st: &Stroked, t: f64, lp: Point, side: Option<WidthSide>) -> Option<f64> {
let d = match side {
None => st.lines.iter().filter_map(|l| l.at(t)).map(|(q, _)| (q - lp).hypot()).fold(f64::INFINITY, f64::min),
Some(s) => {
let (along, _) = pointer_at(st, t, lp)?;
(along * if s == WidthSide::Right { 1.0 } else { -1.0 }).max(0.0)
}
};
(d.is_finite() && st.weight > 0.0).then(|| 2.0 * d / st.weight)
}
@@ -151,7 +190,13 @@ fn width_point(s: &mut Session, p: &Value) -> Result<Value> {
let ve = editing(s)?;
let doc = s.doc()?;
let weight = doc.get(ve.id)?.stroke.weight;
let scale = num(p, "scale").or_else(|| num(p, "width").filter(|_| weight > 0.0).map(|w| w / weight));
let side = match string(p, "side") {
Some(v) => Some(WidthSide::from_id(&v).ok_or_else(|| bad(cmd, "side: left|right"))?),
None => None,
};
// A side's width runs from the centre line to that edge: half of what both sides make.
let per_px = if side.is_some() { 2.0 } else { 1.0 };
let scale = num(p, "scale").or_else(|| num(p, "width").filter(|_| weight > 0.0).map(|w| per_px * w / weight));
let action = string(p, "action").unwrap_or_else(|| if get(p, "index").is_some() { "set".into() } else { "add".into() });
let mut prof = profile_of(doc, ve.id);
if action != "add" && doc.get(ve.id)?.stroke.profile().is_none() {
@@ -160,12 +205,22 @@ fn width_point(s: &mut Session, p: &Value) -> Result<Value> {
let selected = match action.as_str() {
"add" => {
let t = req_num(cmd, p, "t")?;
let sc = scale.unwrap_or_else(|| prof.scale_at(t));
Some(prof.insert(t, sc))
let i = match (scale, side) {
(Some(sc), None) => prof.insert(t, sc),
_ => prof.insert_here(t),
};
if let (Some(sc), Some(sd)) = (scale, side) {
prof.set_side(i, sd, sc);
}
Some(i)
}
"set" | "move" => {
let i = req_index(cmd, p, "index")?;
if !prof.set_point(i, num(p, "t"), scale) {
let ok = match side {
Some(sd) => prof.set_point(i, num(p, "t"), None) && scale.is_none_or(|sc| prof.set_side(i, sd, sc)),
None => prof.set_point(i, num(p, "t"), scale),
};
if !ok {
return Err(bad(cmd, "no such width point"));
}
Some(i)
@@ -189,7 +244,7 @@ pub(crate) fn specs() -> Vec<CommandSpec> {
vec![spec!(
"vector.edit.widthPoint",
"Width point",
"{action?: add|set|remove, index?, t? (0-1 along the path), width? (px) | scale? (× stroke weight)} — add needs t (width defaults to the current one); set moves or resizes point `index`",
"{action?: add|set|remove, index?, t? (0-1 along the path), width? (px) | scale? (× stroke weight)} — add needs t (width defaults to the current one); set moves or resizes point `index`; with `side` only that side of the path changes (looking along it): `width` is then the distance from the centre line to that edge and `scale` that side's scale (× stroke weight, as if both sides were that wide)",
true,
width_point
)]
@@ -211,17 +266,17 @@ impl Session {
return Ok(());
}
let g = match h {
Some(WHit::Handle { index }) => WidthGesture::Size { index, added: None, moved: false },
Some(WHit::Handle { index, side }) => WidthGesture::Size { index, added: None, moved: false, side },
Some(WHit::Point { index }) => WidthGesture::Slide { index, moved: false },
Some(WHit::Path { t }) => {
Some(WHit::Path { t, side }) => {
self.begin_interaction("Variable width")?;
let mut index = 0;
self.preview(|doc, _| {
let mut prof = profile_of(doc, id);
index = prof.insert(t, prof.scale_at(t));
index = prof.insert_here(t);
put_profile(doc, id, Some(&prof))
})?;
WidthGesture::Size { index, added: Some(t), moved: false }
WidthGesture::Size { index, added: Some(t), moved: false, side }
}
None => {
return set_state(self, |e| e.width_point = None);
@@ -249,18 +304,31 @@ impl Session {
})?;
WidthGesture::Slide { index, moved: true }
}
WidthGesture::Size { index, added, .. } => {
WidthGesture::Size { index, added, side, .. } => {
// A handle keeps its side; a point being added widens the side the pointer is on.
let mut side = side;
self.preview(|doc, _| {
let Some(st) = stroked(doc, id) else { return Ok(()) };
let mut prof = profile_of(doc, id);
if let Some(t) = added {
prof.insert(t, prof.scale_at(t));
prof.insert_here(t);
}
let Some(t) = prof.points.get(index).map(|q| q.t) else { return Ok(()) };
prof.set_point(index, None, scale_at_pointer(&st, t, lp));
if added.is_some()
&& let Some((_, s)) = pointer_at(&st, t, lp)
{
side = s;
}
// Alt: that side only; otherwise both sides as wide.
let one_side = ev.alt.then_some(side);
let scale = scale_at_pointer(&st, t, lp, one_side);
match (one_side, scale) {
(Some(s), Some(sc)) => prof.set_side(index, s, sc),
_ => prof.set_point(index, None, scale),
};
put_profile(doc, id, Some(&prof))
})?;
WidthGesture::Size { index, added, moved: true }
WidthGesture::Size { index, added, moved: true, side }
}
};
self.tool.gesture = Gesture::Vector(super::VGesture::Width(g));
@@ -0,0 +1,167 @@
//! One-sided widths with the Variable width tool: Alt-dragging a handle or the path changes one
//! side, a plain drag evens them out, the width point command's `side`, `widthProfile` points with
//! two sides, and what outline stroke and export make of them.
use serde_json::{Value, json};
use super::*;
use crate::EngineError;
use crate::tools::{PointerEvent, PointerKind};
fn ex(s: &mut Session, c: &str, p: Value) -> Value {
s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}"))
}
fn ev(kind: PointerKind, x: f64, y: f64, alt: bool) -> PointerEvent {
PointerEvent { alt, ..PointerEvent::at(kind, x, y, 1.0) }
}
fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64), alt: bool) {
s.pointer(&ev(PointerKind::Down, from.0, from.1, alt)).unwrap();
s.pointer(&ev(PointerKind::Move, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0, alt)).unwrap();
s.pointer(&ev(PointerKind::Move, to.0, to.1, alt)).unwrap();
s.pointer(&ev(PointerKind::Up, to.0, to.1, alt)).unwrap();
}
/// An open path from (0, 50) to (100, 50) (its right side is below it) with a 10 wide centred
/// stroke, in vector edit mode with the Variable width tool.
fn path(s: &mut Session) -> NodeId {
let r = ex(s, "vector.create", json!({"points": [[0, 50], [100, 50]], "closed": false}));
let id = crate::params::id_of(&r["id"]).unwrap();
ex(s, "node.set", json!({"id": id, "props": {"strokes": ["#000000"], "strokeWeight": 10, "strokeAlign": "center"}}));
ex(s, "vector.edit.begin", json!({"id": id}));
ex(s, "vector.edit.tool", json!({"tool": "width"}));
id
}
/// The side scales of width point `i`, rounded.
fn sides(s: &Session, id: NodeId, i: usize) -> (f64, f64) {
let p = s.doc().unwrap().node(id).unwrap().stroke.profile().unwrap().points[i];
let (l, r) = p.sides();
((l * 1e6).round() / 1e6, (r * 1e6).round() / 1e6)
}
fn ve(s: &Session) -> VectorEdit {
s.ds().unwrap().vector_edit.clone().unwrap()
}
#[test]
fn alt_dragging_a_handle_changes_that_side_only() {
let mut s = Session::new();
let id = path(&mut s);
// A point 40 wide in the middle: 20 either side.
ex(&mut s, "vector.edit.widthPoint", json!({"t": 0.5, "width": 40}));
assert_eq!(sides(&s, id, 1), (4.0, 4.0));
s.pointer(&ev(PointerKind::Hover, 50.0, 70.0, true)).unwrap();
assert_eq!(s.tool.overlay.width_hover, Some(WHit::Handle { index: 1, side: WidthSide::Right }));
// Alt-drag the right handle in to 10 from the centre: the left keeps its 20.
drag(&mut s, (50.0, 70.0), (50.0, 60.0), true);
assert_eq!(sides(&s, id, 1), (4.0, 2.0));
let hs = width_handles(s.doc().unwrap(), &ve(&s), 1.0);
let h = hs.iter().find(|h| h.index == 1).unwrap();
assert!(h.one_sided && (h.sides[0][1] - 60.0).abs() < 1e-6 && (h.sides[1][1] - 30.0).abs() < 1e-6, "{h:?}");
assert!((h.width - 30.0).abs() < 1e-6);
// The painted stroke follows: 20 above, 10 below in the middle.
let b = s.doc().unwrap().world_render_bounds(id).unwrap();
assert!(b.y0 <= 30.0 + 1e-6, "{b:?}");
// One undo step; a plain drag makes both sides as wide again.
s.undo().unwrap();
assert_eq!(sides(&s, id, 1), (4.0, 4.0));
s.redo().unwrap();
drag(&mut s, (50.0, 30.0), (50.0, 35.0), false);
assert_eq!(sides(&s, id, 1), (3.0, 3.0));
// Alt past the centre line leaves that side at nothing.
drag(&mut s, (50.0, 35.0), (50.0, 55.0), true);
assert_eq!(sides(&s, id, 1), (0.0, 3.0));
}
#[test]
fn alt_dragging_the_path_adds_a_point_widening_the_side_the_pointer_goes() {
let mut s = Session::new();
let id = path(&mut s);
// From the path at a quarter of the way, up (the left side) to 15 out.
drag(&mut s, (25.0, 50.0), (25.0, 35.0), true);
let pts = s.doc().unwrap().node(id).unwrap().stroke.profile().unwrap().points.clone();
assert_eq!(pts.len(), 3);
assert_eq!(sides(&s, id, 1), (3.0, 1.0), "{pts:?}");
s.undo().unwrap();
assert!(s.doc().unwrap().node(id).unwrap().stroke.profile().is_none(), "adding and sizing is one step");
}
#[test]
fn the_width_point_command_sets_one_side() {
let mut s = Session::new();
let id = path(&mut s);
ex(&mut s, "vector.edit.widthPoint", json!({"t": 0.5, "width": 20}));
// `width` with a side: the distance from the centre line to that edge.
ex(&mut s, "vector.edit.widthPoint", json!({"index": 1, "side": "left", "width": 15}));
assert_eq!(sides(&s, id, 1), (3.0, 2.0));
ex(&mut s, "vector.edit.widthPoint", json!({"index": 1, "side": "right", "scale": 0.5}));
assert_eq!(sides(&s, id, 1), (3.0, 0.5));
// Adding with a side widens that side of what is there.
let r = ex(&mut s, "vector.edit.widthPoint", json!({"t": 0.9, "side": "right", "width": 25}));
let i = r["index"].as_u64().unwrap() as usize;
let (l, rr) = sides(&s, id, i);
assert!((rr - 5.0).abs() < 1e-6 && l > 0.9 && l < 3.0, "{l} {rr}");
// State and inspect report two-sided points as [t, left, right].
let n = ex(&mut s, "node.get", json!({"id": id}));
let p1: Vec<f64> = n["widthProfile"]["points"][1].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
assert!(p1.len() == 3 && [0.5, 3.0, 0.5].iter().zip(&p1).all(|(a, b)| (a - b).abs() < 1e-9), "{p1:?}");
// A width without a side evens the point out again.
ex(&mut s, "vector.edit.widthPoint", json!({"index": 1, "width": 10}));
assert_eq!(sides(&s, id, 1), (1.0, 1.0));
assert!(s.execute("vector.edit.widthPoint", &json!({"index": 1, "side": "up", "width": 3})).is_err());
}
#[test]
fn profiles_with_two_sides_round_trip_and_outline() {
let mut s = Session::new();
let id = path(&mut s);
ex(&mut s, "vector.edit.end", json!({}));
// [t, left, right] and {t, left, right}: all of the width below the path.
ex(&mut s, "node.set", json!({"id": id, "props": {"widthProfile": [[0, 0, 2], {"t": 1, "left": 0, "right": 2}]}}));
assert_eq!(sides(&s, id, 0), (0.0, 2.0));
assert_eq!(sides(&s, id, 1), (0.0, 2.0));
let back = ex(&mut s, "node.get", json!({"id": id}))["widthProfile"].clone();
ex(&mut s, "node.set", json!({"id": id, "props": {"widthProfile": null}}));
ex(&mut s, "node.set", json!({"id": id, "props": {"widthProfile": back}}));
assert_eq!(sides(&s, id, 1), (0.0, 2.0));
// Exports render it; outline stroke makes a vector 10 tall, all below the path.
let doc = s.doc().unwrap().clone();
for f in [ExportFormat::Svg, ExportFormat::Pdf, ExportFormat::Png] {
assert!(!crate::cmd::file::export_bytes(&doc, &[id], f, 1.0).unwrap().is_empty());
}
let r = ex(&mut s, "vector.outlineStroke", json!({"ids": [id]}));
let v = crate::params::id_of(&r["ids"][0]).unwrap();
let b = s.doc().unwrap().world_bounds(v).unwrap();
assert!((b.y0 - 50.0).abs() < 0.05 && (b.y1 - 60.0).abs() < 0.05 && (b.width() - 100.0).abs() < 0.05, "{b:?}");
}
#[test]
fn one_sided_widths_survive_junk() {
let junk = [
json!({"index": 1, "side": 7, "width": 1}),
json!({"index": 1, "side": "left", "width": "NaN"}),
json!({"index": 1, "side": "right", "scale": 1e308}),
json!({"t": 0.5, "side": "left", "scale": -1e9}),
json!({"index": 99, "side": "left", "width": 1}),
];
for j in &junk {
let mut s = Session::new();
let id = path(&mut s);
ex(&mut s, "vector.edit.widthPoint", json!({"t": 0.5, "width": 20}));
if let Err(EngineError::Internal { cmd, msg }) = s.execute("vector.edit.widthPoint", j) {
panic!("{cmd} panicked on {j}: {msg}");
}
let p = s.doc().unwrap().node(id).unwrap().stroke.profile().cloned().unwrap();
assert!(p.points.iter().all(|q| q.scale.is_finite() && q.bias.is_finite() && (-1.0..=1.0).contains(&q.bias)), "{j}: {p:?}");
}
for v in [json!([[0, 1, "x"]]), json!([{"t": 0, "left": 1}]), json!([[0, -5, 1e308], [1, f64::NAN, 2]])] {
let mut s = Session::new();
let id = path(&mut s);
let _ = s.execute("node.set", &json!({"id": id, "props": {"widthProfile": v}}));
if let Some(p) = s.doc().unwrap().node(id).unwrap().stroke.profile() {
assert!(p.points.iter().all(|q| q.scale.is_finite() && q.scale >= 0.0 && q.bias.is_finite()));
}
}
}
+2 -2
View File
@@ -79,7 +79,7 @@ fn width_tool_drags_add_slide_and_size_points_in_one_step_each() {
assert_eq!(ve(&s).tool, VTool::Width);
// Hovering the stroke offers to add a point there.
s.pointer(&ev(PointerKind::Hover, 50.0, 52.0)).unwrap();
assert!(matches!(s.tool.overlay.width_hover, Some(WHit::Path { t }) if (t - 0.5).abs() < 1e-9));
assert!(matches!(s.tool.overlay.width_hover, Some(WHit::Path { t, side: WidthSide::Right }) if (t - 0.5).abs() < 1e-9));
// Dragging from the path adds a point and sets its width: 20 from the centre = 40 wide.
drag(&mut s, (50.0, 50.0), (50.0, 70.0));
assert_eq!(points(&s, id), vec![(0.0, 1.0), (0.5, 4.0), (1.0, 1.0)]);
@@ -94,7 +94,7 @@ fn width_tool_drags_add_slide_and_size_points_in_one_step_each() {
let h = hs.iter().find(|h| h.index == 1).unwrap();
assert!((h.sides[0][1] - 70.0).abs() < 1e-6 || (h.sides[1][1] - 70.0).abs() < 1e-6, "{h:?}");
s.pointer(&ev(PointerKind::Hover, 50.0, 30.0)).unwrap();
assert_eq!(s.tool.overlay.width_hover, Some(WHit::Handle { index: 1 }));
assert_eq!(s.tool.overlay.width_hover, Some(WHit::Handle { index: 1, side: WidthSide::Left }));
drag(&mut s, (50.0, 30.0), (50.0, 45.0));
assert_eq!(points(&s, id), vec![(0.0, 1.0), (0.5, 1.0), (1.0, 1.0)]);
// Dragging the point slides it along the path.