mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 20:36:55 -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:
@@ -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));
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]]})));
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user