Merge remote-tracking branch 'origin/main' into feat/spell-check

# Conflicts:
#	ROADMAP.md
#	docs/gaps.md
#	docs/roadmap.md
#	docs/target-app-parity.md
#	docs/ui-parity.md
This commit is contained in:
bflatastic
2026-10-11 10:22:24 +07:00
99 changed files with 7284 additions and 1106 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}");
}
}
+1
View File
@@ -32,6 +32,7 @@ pub static MENU: &[MenuItem] = &[
m!("File", SEP, ""),
// FigCraft's own (live sessions): not in the reference menu, so not counted by `parity`.
m!("File", "Join live session…", "collab.join"),
m!("File", "Publish to server…", "collab.publish"),
m!("File", SEP, ""),
m!("File", "Export…", "file.export"),
m!("File", "Export frames to PDF…", "file.exportFramesPdf"),
+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),
+5 -1
View File
@@ -6,7 +6,7 @@ use serde_json::{Value, json};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::tools::ToolId;
use crate::{Result, Session, ViewState, bad, spec};
use crate::{EngineError, Result, Session, ViewState, bad, spec};
fn view(s: &mut Session) -> Result<&mut ViewState> {
Ok(&mut s.ds_mut()?.view)
@@ -125,6 +125,10 @@ fn pref(s: &mut Session, p: &Value, f: fn(&mut crate::Prefs) -> &mut bool) -> Re
}
fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
// A file you can't edit now (view only, or on its way to a server): moving around only.
if let Some(why) = s.active_read_only().filter(|_| !matches!(t, ToolId::Move | ToolId::Hand)) {
return Err(EngineError::Disabled(format!("tool.{}", t.id()), why.into()));
}
s.set_tool(t);
Ok(json!({"tool": t.id()}))
}
+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) => {
+25 -1
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;
@@ -630,7 +631,8 @@ impl Session {
let before = self.ds().ok().map(|d| (d.doc.clone(), d.selection.clone(), d.page, d.uid));
let result = guard::run(id, || (spec.run)(self, params))
.and_then(|v| self.check_instance_structure(id, &before).map(|()| v))
.and_then(|v| self.check_preview(id, &before).map(|()| v));
.and_then(|v| self.check_preview(id, &before).map(|()| v))
.and_then(|v| self.check_live_access(id, &before).map(|()| v));
match &result {
Ok(_) => {
self.after_command(spec, before);
@@ -667,6 +669,17 @@ impl Session {
Err(EngineError::Disabled(id.to_string(), PREVIEW_READ_ONLY.into()))
}
/// A live session's document you may only view (or that is on its way to a server) is
/// read-only: refuse an edit (it is rolled back).
fn check_live_access(&self, id: &str, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) -> Result<()> {
let Some((bdoc, _, _, uid)) = before else { return Ok(()) };
let Some(ds) = self.docs.iter().find(|d| d.uid == *uid) else { return Ok(()) };
match self.live_read_only(*uid) {
Some(why) if !Arc::ptr_eq(&ds.doc, bdoc) => Err(EngineError::Disabled(id.to_string(), why.into())),
_ => Ok(()),
}
}
/// Instances can't gain, lose or reorder layers, and library copies are read-only: refuse such
/// an edit (it is rolled back).
fn check_instance_structure(&self, id: &str, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) -> Result<()> {
@@ -741,6 +754,9 @@ impl Session {
/// Start a gesture: later [`preview`](Self::preview) calls re-apply edits to this snapshot.
pub fn begin_interaction(&mut self, label: &str) -> Result<()> {
textpreview::end(self);
if let Some(why) = self.active_read_only() {
return Err(EngineError::Disabled(label.to_string(), why.into()));
}
let ds = self.ds_mut()?;
if ds.preview.is_some() {
return Err(EngineError::Disabled(label.to_string(), PREVIEW_READ_ONLY.into()));
@@ -1133,12 +1149,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;
@@ -1179,6 +1199,8 @@ mod keymap_tests;
#[cfg(test)]
mod library_variables_tests;
#[cfg(test)]
mod live_access_tests;
#[cfg(test)]
mod live_tests;
#[cfg(test)]
mod mask_tests;
@@ -1225,6 +1247,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;
+137 -4
View File
@@ -16,6 +16,11 @@
//! through the client and installed with [`Session::apply_remote`], which never touches the undo
//! history. After a dropped connection the client resumes with its id and catches up; what it
//! made meanwhile goes out then.
//!
//! A file can also live on a `figcraft-server`: `collab.publish` uploads the open document and
//! syncs that tab through the server, whose share links let others edit or only view. A view link
//! makes the synced document read-only here (every edit is refused before it is sent; the server
//! refuses it too).
use std::collections::BTreeMap;
use std::net::IpAddr;
@@ -23,6 +28,8 @@ use std::sync::Arc;
use figcraft_collab::Client;
use figcraft_collab::message::{ClientMsg, EditingText, ServerMsg};
pub use figcraft_collab::message::Access;
use figcraft_doc::{Document, NodeId};
use serde::Serialize;
use serde_json::{Value, json};
@@ -32,6 +39,7 @@ pub use figcraft_collab::message::{Peer, Presence};
use crate::cmd::CommandSpec;
use crate::collab::EngineRecompute;
use crate::params::{get, num, req_string, string};
use crate::tools::ToolId;
use crate::{DocState, EngineError, Result, Session, TextEdit, ViewState, bad, spec};
/// Most selected ids one presence update carries (the rest aren't shown to others).
@@ -127,10 +135,28 @@ pub struct SessionInfo {
/// invite.
pub link: String,
pub url: String,
/// `ip:port` the session listens on (host) or connects to (guest).
/// `ip:port` the session listens on (host) or connects to (guest); a server's URL when it
/// isn't plain `ws://` at `/`.
pub address: String,
/// Reachable from other machines (the host listens on a network address, not loopback).
pub network: bool,
/// What you may do: edit, or only view (a server's view link).
pub access: Access,
}
/// A file you published to a server: where, and its first share links (they carry their tokens:
/// share them like passwords; the server keeps only hashes).
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Published {
/// The server's address.
pub server: String,
/// The file's id and name on the server.
pub file: String,
pub name: String,
/// The link that lets people edit, and the one that lets them only view.
pub edit: String,
pub view: String,
}
/// What happened in the session since the last [`LiveLink::poll`].
@@ -152,6 +178,8 @@ pub enum Event {
Ended {
reason: String,
},
/// The document is on the server (`collab.publish`); joining it follows.
Published(Published),
}
/// Which network interface a hosted session listens on.
@@ -177,6 +205,21 @@ pub struct HostOptions {
pub doc: Arc<Document>,
}
/// What [`LiveLink::publish`] needs to put the open file on a server.
#[derive(Clone, Debug)]
pub struct PublishOptions {
/// The server's address (`ws://host:port/`, `wss://host/path/`), which may carry its key
/// (`?key=…`).
pub server: String,
/// The server's key, when the address doesn't carry it.
pub key: Option<String>,
/// The file's name on the server.
pub name: String,
/// Your name in the session (empty: the server picks one).
pub me: String,
pub doc: Arc<Document>,
}
/// The networking behind live sessions, installed by the app ([`Live::install`]). Calls return
/// at once; what happens later arrives through [`poll`](Self::poll).
pub trait LiveLink: Send {
@@ -189,6 +232,12 @@ pub trait LiveLink: Send {
/// Start joining the session behind an invite link (ending any current one). Errors are for
/// links that can't be used; they never echo the link (it holds the token).
fn join(&mut self, link: &str, name: &str) -> std::result::Result<(), String>;
/// Start uploading `opts.doc` to a server as a new file, then join it with its edit link
/// (ending any current session): [`Event::Published`] with its links, then the welcome.
/// Errors are for addresses that can't be used; they never echo the key.
fn publish(&mut self, _opts: PublishOptions) -> std::result::Result<(), String> {
Err("publishing to a server needs the desktop app or the MCP server".into())
}
/// End the current session (stop hosting or leave).
fn leave(&mut self);
/// Your latest presence (the link throttles and coalesces).
@@ -238,6 +287,8 @@ pub struct Live {
/// Registers the app as the opener of `figcraft://` invite links (the desktop app on Windows
/// installs one; see [`crate::invite_links`]).
pub links: Option<Box<dyn crate::invite_links::LinkRegistrar>>,
/// The file you published to a server in this session, with its links.
pub published: Option<Published>,
}
impl Live {
@@ -274,6 +325,7 @@ impl Live {
self.synced = None;
self.deferred.clear();
self.hold = 0;
self.published = None;
}
/// The document sync client (once the welcome arrived): peers, pending batches, the user's
@@ -349,6 +401,11 @@ pub fn editing_hint(names: &[&str]) -> Option<String> {
})
}
/// Why an edit of a server file you joined with a view link is refused.
pub const VIEW_ONLY: &str = "View only: the link you joined with lets you see this file, not change it";
/// Why an edit of a file on its way to a server is refused.
pub const PUBLISHING: &str = "The file is being published: you can edit it again once it is on the server";
/// A gesture, version preview or live text preview shows a document that isn't the edit to
/// sync yet.
fn busy(ds: &DocState) -> bool {
@@ -504,6 +561,10 @@ impl Session {
self.message = Some(reason);
live.reset();
}
Event::Published(p) => {
self.message = Some(format!("Published {} to the server", p.name));
live.published = Some(p);
}
}
}
@@ -533,8 +594,19 @@ impl Session {
self.live.synced = None;
}
(Some(Role::Guest), Some(uid)) => {
// Joining again in this tab, or the file you published arriving from the server.
let doc = Arc::new(client.document().clone());
let was_dirty = self.docs.iter().find(|d| d.uid == uid).is_some_and(DocState::is_dirty);
self.apply_remote(uid, doc.clone());
if let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) {
// Undo is per user from now on: earlier whole-document steps would revert
// others' edits.
ds.history.undo.clear();
ds.history.redo.clear();
if !was_dirty {
ds.mark_saved();
}
}
self.live.synced = Some(doc);
}
(Some(Role::Guest), None) => {
@@ -552,6 +624,10 @@ impl Session {
info.file_name = doc.name.clone();
info.pages = doc.pages.iter().map(|p| PageInfo { id: p.0, name: doc.node(*p).map(|n| n.name.clone()).unwrap_or_default() }).collect();
}
// A view link: the editing tools are off.
if client.access() == Access::View && !matches!(self.tool.tool, ToolId::Move | ToolId::Hand) {
self.set_tool(ToolId::Move);
}
self.live.client = Some(client);
self.live.deferred.clear();
}
@@ -573,6 +649,25 @@ impl Session {
self.live_edit(None);
}
/// Why document `uid` can't be edited now, if it can't: it is the session's document and you
/// may only view it, or it is on its way to a server.
pub fn live_read_only(&self, uid: u64) -> Option<&'static str> {
if self.live.doc_uid != Some(uid) {
return None;
}
match &self.live.client {
Some(c) if c.access() == Access::View => Some(VIEW_ONLY),
Some(_) => None,
None if self.live.status.active() && self.live.role() != Some(Role::Host) => Some(PUBLISHING),
None => None,
}
}
/// The active document can't be edited now (see [`live_read_only`](Self::live_read_only)).
pub fn active_read_only(&self) -> Option<&'static str> {
self.ds().ok().and_then(|ds| self.live_read_only(ds.uid))
}
/// Whether document `uid` is the live session's synced document: its edits sync and its undo
/// history is the user's own.
pub fn live_doc(&self, uid: u64) -> bool {
@@ -588,6 +683,13 @@ impl Session {
if self.live.hold > 0 || busy(ds) || self.live.synced.as_ref().is_some_and(|s| Arc::ptr_eq(s, &ds.doc)) {
return;
}
if self.live_read_only(uid).is_some() {
// Nothing goes out: what changed outside a command is put back.
if let Some(s) = self.live.synced.clone() {
self.apply_remote(uid, s);
}
return;
}
let doc = ds.doc.clone();
let Some(client) = self.live.client.as_mut() else { return };
match client.edit_with(&doc, label) {
@@ -793,6 +895,9 @@ impl Session {
"url": info.map(|i| i.url.as_str()),
"address": info.map(|i| i.address.as_str()),
"network": info.map(|i| i.network),
"access": l.client.as_ref().map(Client::access).or(info.map(|i| i.access)),
"readOnly": self.active_read_only().is_some(),
"published": l.published,
"follow": l.follow,
"syncing": l.syncing(),
"document": l.doc_uid.and_then(|u| self.docs.iter().position(|d| d.uid == u)),
@@ -831,6 +936,23 @@ fn host(s: &mut Session, p: &Value) -> Result<Value> {
Ok(s.live_summary())
}
fn publish(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "collab.publish";
let server = req_string(CMD, p, "server")?;
let key = string(p, "key").map(|k| k.trim().to_string()).filter(|k| !k.is_empty());
let (doc, uid) = s.ds().map(|ds| (ds.doc.clone(), ds.uid))?;
let name = string(p, "name").map(|n| n.trim().chars().filter(|c| !c.is_control()).take(256).collect::<String>()).filter(|n| !n.is_empty());
let opts =
PublishOptions { server: server.trim().to_string(), key, name: name.unwrap_or_else(|| doc.name.clone()), me: display_name(&s.prefs), doc };
let link = s.live.link.as_mut().ok_or_else(|| unavailable(CMD))?;
link.publish(opts).map_err(|e| bad(CMD, e))?;
s.live.reset();
s.live.status = Status::Connecting;
// This tab becomes the server file's synced document once it is there.
s.live.doc_uid = Some(uid);
Ok(s.live_summary())
}
fn join(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "collab.join";
let link_text = req_string(CMD, p, "link")?;
@@ -844,10 +966,14 @@ fn join(s: &mut Session, p: &Value) -> Result<Value> {
fn leave(s: &mut Session, _: &Value) -> Result<Value> {
let was = s.live.status.active();
let kept = s.live.syncing() && s.live.role() == Some(Role::Guest);
if let Some(l) = s.live.link.as_mut() {
l.leave();
}
s.live.reset();
if kept {
s.message = Some("You left the session; your copy stays open here".into());
}
Ok(json!({"ended": was}))
}
@@ -887,15 +1013,22 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"collab.join",
"Join live session",
"{link: figcraft://join?host=…&file=…&token=… or ws://host:port/?file=…&token=…} → status \"connecting\"; `collab.peers` tells when it's live",
"{link: figcraft://join?host=…&file=…&token=…, figcraft://join?server=ws(s)://…&file=…&token=… or ws(s)://host:port/?file=…&token=… (a session an app hosts, or a server's share link)} → status \"connecting\"; `collab.peers` tells when it's live",
false,
join
),
spec!("collab.leave", "Leave live session", "{} → {ended}", false, leave),
spec!("collab.leave", "Leave live session", "{} (the synced file stays open as a local copy) → {ended}", false, leave),
spec!(
"collab.publish",
"Publish to server",
"{server: ws://host:port/?key=… or wss://host/path/?key=… (the server's address with its key; `figcraft-server key` prints it), key? (when the address has none), name? (default: the file's name)} → status \"connecting\": the open file is uploaded as a new file and this tab syncs through the server; `collab.peers` then has `published: {server, file, name, edit, view}` (share links: edit, or view only)",
false,
publish
),
spec!(
"collab.peers",
"People in this file",
"{} → {available, canHost (false in the browser: it can only join), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}]}",
"{} → {available, canHost (false in the browser: it can only join), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, access: edit|view (view: a server's view link; edits are refused), readOnly (the open file can't be edited now: view only, or being published), published (a file you published: {server, file, name, edit, view}), follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}]}",
false,
|s, _| Ok(s.live_summary())
),
+127
View File
@@ -0,0 +1,127 @@
//! Files on a server, against a scripted link: `collab.publish` makes the open tab the server
//! file's synced document (read-only until it is there, its undo history per person after), and
//! a view link makes the synced document read-only (commands, gestures, editing tools and changes
//! made outside a command are all refused or put back; others' edits still arrive; other tabs stay
//! editable). Leaving keeps the file open as your copy.
use figcraft_collab::message::{ClientMsg, ServerMsg};
use figcraft_collab::{Client, NoRecompute, Outgoing};
use serde_json::json;
use crate::live::{Access, Event, PUBLISHING, Published, Role, VIEW_ONLY};
use crate::live_tests::{info, push, relay, serve_as, session};
use crate::tools::ToolId;
use crate::{EngineError, Session};
fn refused(s: &mut Session, cmd: &str, params: serde_json::Value, why: &str) {
match s.execute(cmd, &params) {
Err(EngineError::Disabled(_, reason)) => assert_eq!(reason, why, "{cmd}"),
other => panic!("{cmd}: expected a refusal, got {other:?}"),
}
}
#[test]
fn publishing_makes_the_open_tab_the_server_file() {
let (mut s, script) = session();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap();
assert!(s.undo_depth().0 > 0);
let e = s.execute("collab.publish", &json!({"server": "nope"})).unwrap_err();
assert!(e.to_string().contains("not a server address"), "{e}");
let r = s.execute("collab.publish", &json!({"server": " ws://127.0.0.1:47900/?key=k ", "name": " Team\n"})).unwrap();
assert_eq!(r["status"], "connecting");
assert_eq!(r["readOnly"], true);
{
let sc = script.lock().unwrap();
assert_eq!(sc.published.len(), 1);
assert_eq!(sc.published[0].server, "ws://127.0.0.1:47900/?key=k");
assert_eq!(sc.published[0].name, "Team");
assert_eq!(sc.published[0].doc.node_count(), s.doc().unwrap().node_count());
}
// Until it is on the server nothing changes it (and the name defaults to the file's).
refused(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 5, "height": 5}), PUBLISHING);
refused(&mut s, "tool.set", json!({"tool": "rectangle"}), PUBLISHING);
assert!(s.execute("tool.set", &json!({"tool": "hand"})).is_ok(), "moving around is fine");
assert!(s.begin_interaction("Move").is_err());
// The server's welcome: this tab is the synced document, undo is per person from now on.
let mut doc = s.doc().unwrap().clone();
doc.name = "Team".into();
let (mut server, id, welcome) = serve_as(&doc, Access::Edit);
let published = Published { server: "127.0.0.1:47900".into(), file: "f".into(), name: "Team".into(), edit: "e".into(), view: "v".into() };
push(&script, vec![Event::Published(published.clone()), Event::Welcome { info: info(Role::Guest, id), peers: vec![] }, welcome]);
s.live_poll();
assert!(s.live.syncing());
assert_eq!(s.docs.len(), 1, "no new tab");
assert_eq!(s.doc().unwrap().name, "Team");
assert_eq!(s.undo_depth(), (0, 0));
assert_eq!(s.active_read_only(), None);
assert_eq!(s.live.published, Some(published));
assert_eq!(s.message.as_deref(), Some("Published Team to the server"));
assert_eq!(s.live_summary()["published"]["name"], "Team");
// Edits sync through the server.
let rect = s.execute("shape.rectangle", &json!({"x": 100, "y": 0, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
let answers = relay(&script, &mut server, id);
assert!(server.document().contains(figcraft_doc::NodeId(rect)));
push(&script, answers);
s.live_poll();
assert_eq!(s.undo_depth(), (1, 0));
// Leaving keeps the file open, yours to edit.
s.execute("collab.leave", &json!({})).unwrap();
assert_eq!(s.docs.len(), 1);
assert!(s.doc().unwrap().contains(figcraft_doc::NodeId(rect)));
assert_eq!(s.message.as_deref(), Some("You left the session; your copy stays open here"));
assert!(s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_ok());
}
#[test]
fn a_view_link_makes_the_file_read_only() {
let (mut s, script) = session();
s.execute("tool.set", &json!({"tool": "rectangle"})).unwrap();
s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap();
let mut doc = figcraft_doc::Document::new("Shared");
let page = doc.first_page().unwrap();
let card = doc.create(page, figcraft_doc::NodeKind::Rectangle, "Card", 0.0, 0.0, 10.0, 10.0).unwrap();
let (mut server, id, welcome) = serve_as(&doc, Access::View);
let mut me = info(Role::Guest, id);
me.access = Access::View;
push(&script, vec![Event::Welcome { info: me, peers: vec![] }, welcome]);
s.live_poll();
assert!(s.live.syncing());
assert_eq!(s.active_read_only(), Some(VIEW_ONLY));
let summary = s.live_summary();
assert_eq!((summary["access"].as_str(), summary["readOnly"].as_bool()), (Some("view"), Some(true)));
assert_eq!(s.tool.tool, ToolId::Move, "the editing tool was put away");
// Commands, tools and gestures are refused; nothing changes and nothing is sent.
let before = s.doc().unwrap().clone();
refused(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}), VIEW_ONLY);
refused(&mut s, "node.set", json!({"ids": [card.0], "props": {"name": "Mine"}}), VIEW_ONLY);
refused(&mut s, "edit.delete", json!({"ids": [card.0]}), VIEW_ONLY);
refused(&mut s, "tool.set", json!({"tool": "text"}), VIEW_ONLY);
assert!(matches!(s.begin_interaction("Move"), Err(EngineError::Disabled(..))));
assert_eq!(*s.doc().unwrap(), before);
assert!(s.execute("select.set", &json!({"ids": [card.0]})).is_ok(), "selecting is fine");
// A change made outside a command is put back, never sent.
s.ds_mut().unwrap().doc_mut().node_mut(card).unwrap().name = "Outside".into();
s.live_sync();
assert_eq!(s.doc().unwrap().node(card).unwrap().name, "Card");
assert!(script.lock().unwrap().msgs.iter().all(|m| !matches!(m, ClientMsg::Changes { .. })));
// Someone who may edit renames the card: it arrives.
let ClientMsg::Hello(h) = Client::hello("f", "", "Bo", "") else { panic!() };
let (bo_id, outs) = server.join(h).unwrap();
let Some(Outgoing::To(_, w)) = outs.into_iter().next() else { panic!() };
let mut bo = Client::join(w).unwrap();
let mut d = bo.document().clone();
d.node_mut(card).unwrap().name = "Bo's card".into();
let m = bo.edit(&d).unwrap().unwrap();
for o in server.receive(bo_id, m) {
if let Outgoing::All(m @ ServerMsg::Changes(_)) = o {
bo.receive(m.clone(), &NoRecompute).unwrap();
push(&script, vec![Event::Sync(m)]);
}
}
s.live_poll();
assert_eq!(s.doc().unwrap().node(card).unwrap().name, "Bo's card");
// Your other tabs stay yours.
s.active = 0;
assert_eq!(s.active_read_only(), None);
assert!(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_ok());
}
+19 -5
View File
@@ -8,7 +8,7 @@ use figcraft_collab::{Client, Limits, Outgoing, Server};
use figcraft_doc::{Document, NodeId};
use serde_json::json;
use crate::live::{Event, HostOptions, LiveLink, Peer, Presence, Role, SessionInfo, Status};
use crate::live::{Access, Event, HostOptions, LiveLink, Peer, Presence, PublishOptions, Role, SessionInfo, Status};
use crate::{EngineError, Session};
/// What the scripted link was asked to do, and the events it hands out next.
@@ -20,6 +20,7 @@ pub(crate) struct Script {
joined: Vec<String>,
left: usize,
pub(crate) msgs: Vec<ClientMsg>,
pub(crate) published: Vec<PublishOptions>,
}
pub(crate) struct Mock(pub(crate) Arc<Mutex<Script>>);
@@ -36,6 +37,13 @@ impl LiveLink for Mock {
self.0.lock().unwrap().joined.push(link.to_string());
Ok(())
}
fn publish(&mut self, opts: PublishOptions) -> Result<(), String> {
if !opts.server.starts_with("ws") {
return Err("not a server address".into());
}
self.0.lock().unwrap().published.push(opts);
Ok(())
}
fn leave(&mut self) {
self.0.lock().unwrap().left += 1;
}
@@ -62,6 +70,7 @@ pub(crate) fn info(role: Role, me: u32) -> SessionInfo {
url: "ws://127.0.0.1:47900/?file=f&token=t".into(),
address: "127.0.0.1:47900".into(),
network: false,
access: crate::live::Access::Edit,
}
}
@@ -69,28 +78,33 @@ fn peer(id: u32, name: &str) -> Peer {
Peer { client_id: id, name: name.into(), color: "#9747FF".into() }
}
fn session() -> (Session, Arc<Mutex<Script>>) {
pub(crate) fn session() -> (Session, Arc<Mutex<Script>>) {
let mut s = Session::new();
let script = Arc::new(Mutex::new(Script::default()));
s.live.install(Box::new(Mock(script.clone())));
(s, script)
}
fn push(script: &Arc<Mutex<Script>>, events: Vec<Event>) {
pub(crate) fn push(script: &Arc<Mutex<Script>>, events: Vec<Event>) {
script.lock().unwrap().events.extend(events);
}
/// A session server for `doc` and the welcome it gives a first client (as `Event::Sync`).
fn serve(doc: &Document) -> (Server, u32, Event) {
serve_as(doc, Access::Edit)
}
/// [`serve`] for a client that may do `access`.
pub(crate) fn serve_as(doc: &Document, access: Access) -> (Server, u32, Event) {
let mut server = Server::new(doc.clone(), "", Limits::default());
let ClientMsg::Hello(h) = Client::hello("f", "", "Me", "") else { panic!("no hello") };
let (id, outs) = server.join(h).unwrap();
let (id, outs) = server.join_as(h, access).unwrap();
let w = outs.into_iter().find_map(|o| if let Outgoing::To(_, w @ ServerMsg::Welcome { .. }) = o { Some(w) } else { None }).unwrap();
(server, id, Event::Sync(w))
}
/// Feed what the session sent to the server; the server's answers for the session, in order.
fn relay(script: &Arc<Mutex<Script>>, server: &mut Server, from: u32) -> Vec<Event> {
pub(crate) fn relay(script: &Arc<Mutex<Script>>, server: &mut Server, from: u32) -> Vec<Event> {
let msgs = std::mem::take(&mut script.lock().unwrap().msgs);
let mut out = Vec::new();
for m in msgs {
+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.