Files
wordcraft/crates/engine/src/cmd/connectors.rs
T

418 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Connectors: gluing a connector's ends to shapes' connection sites (`shape.connect`), and
//! re-routing glued connectors when the shapes they join move or change size ([`reroute`], run by
//! the session after object edits).
//!
//! Floating connectors in the body are routed on the page (from the layout); connectors inside a
//! group, in the group's own space. A glue to a shape that is gone (or on another page) leaves
//! that end where it is.
use std::collections::HashMap;
use serde_json::{Value, json};
use wordcraft_doc::connector::{ConnEnd, Frame, ends, nearest_site, route, site_point};
use wordcraft_doc::para::{Anchor, InlineObject, Wrap};
use wordcraft_doc::{Pos, StoryRef};
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
/// How close (points) a dragged end must come to a connection site to glue to it.
pub const SNAP: f32 = 12.0;
/// Largest offset a routed connector gets, points.
const MAX_OFFSET: f32 = 4000.0;
fn has_connector(s: &Session) -> Option<&'static str> {
match super::objects::selected(s) {
Some((_, InlineObject::Shape { kind, .. })) if kind.is_connector() => None,
_ => Some("select a connector first"),
}
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("shape.connect", "Connect Shapes", "Shape Format › Insert Shapes", connect)
.params(
r#"{"start"?: end, "end"?: end} end: {"path": [..], "off": n, "site"?: n} (glue to that body shape's connection site; nearest one when omitted) | {"x": pt, "y": pt, "page"?: n} (drag the end there: it glues to a shape's site within 12 pt, else stays free there) | null (unglue)"#,
)
.when(has_connector),
]
}
/// Commands after which glued connectors are re-routed: those that move, resize, turn, add or
/// change drawings.
pub fn reroutes_after(id: &str) -> bool {
id.starts_with("arrange.") || id.starts_with("shape.") || id.starts_with("picture.") || id == "insert.shape" || id.starts_with("draw.")
}
/// The body's floating drawings with an id (by id) and its glued connectors.
fn scan(s: &Session) -> (HashMap<u32, Pos>, Vec<Pos>, Vec<Pos>) {
let (mut ids, mut conns, mut groups) = (HashMap::new(), Vec::new(), Vec::new());
for path in s.doc.para_paths(StoryRef::Body) {
let Some(para) = s.doc.para(StoryRef::Body, &path) else { continue };
for off in para.object_offsets() {
let pos = Pos { story: StoryRef::Body, path: path.clone(), off };
match para.object_at(off) {
Some(InlineObject::Shape { kind, extra, float, .. }) => {
if extra.id != 0 {
ids.entry(extra.id).or_insert(pos.clone());
}
if kind.is_connector() && float.wrap != Wrap::Inline && (extra.start.is_some() || extra.end.is_some()) {
conns.push(pos);
}
}
Some(InlineObject::Group { children, .. })
if children.iter().any(|c| matches!(&c.obj, InlineObject::Shape { kind, extra, .. } if kind.is_connector() && (extra.start.is_some() || extra.end.is_some()))) =>
{
groups.push(pos)
}
_ => {}
}
}
}
(ids, conns, groups)
}
/// Re-route every glued connector to the shapes it joins. Returns whether anything moved.
pub fn reroute(s: &mut Session) -> bool {
let (ids, conns, groups) = scan(s);
let mut moved = false;
for pos in groups {
if let Ok(para) = s.doc.para_mut(pos.story, &pos.path)
&& let Some(group @ InlineObject::Group { .. }) = para.object_at_mut(pos.off)
&& wordcraft_doc::connector::reroute_group(group)
{
para.touch();
moved = true;
}
}
if conns.is_empty() || ids.is_empty() {
return moved;
}
if moved {
s.touch();
}
let layout = s.layout();
let site = |e: Option<ConnEnd>, page: usize| -> Option<(f32, f32)> {
let e = e?;
let target = ids.get(&e.id)?;
let hit = layout.object(target, page)?;
let kind = match s.doc.para_at(target)?.object_at(target.off)? {
InlineObject::Shape { kind, .. } => *kind,
_ => return None,
};
(hit.page == page).then(|| site_point(kind, [hit.rect.x, hit.rect.y, hit.rect.w, hit.rect.h], hit.spin, e.site))?
};
let mut plans = Vec::new();
for pos in conns {
let Some(hit) = layout.object(&pos, s.page_hint) else { continue };
let Some(InlineObject::Shape { extra, float, .. }) = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)) else { continue };
let r = hit.rect;
let (s0, e0) = ends(r.x, r.y, r.w, r.h, float.flip_h, float.flip_v);
let to = route(site(extra.start, hit.page).unwrap_or(s0), site(extra.end, hit.page).unwrap_or(e0));
plans.push((pos, to, (r.x, r.y), (hit.origin.x, hit.origin.y)));
}
for (pos, to, at, origin) in plans {
moved |= place(s, &pos, to, at, origin);
}
moved
}
/// Put the floating connector at `pos` (now with its frame's top-left at page point `at`, its
/// anchor's origin at `origin`) in frame `to`. Returns whether it changed.
fn place(s: &mut Session, pos: &Pos, to: Frame, at: (f32, f32), origin: (f32, f32)) -> bool {
let Ok(para) = s.doc.para_mut(pos.story, &pos.path) else { return false };
let Some(InlineObject::Shape { w, h, float, .. }) = para.object_at_mut(pos.off) else { return false };
let near = |a: f32, b: f32| (a - b).abs() < 0.01;
if near(to.x, at.0)
&& near(to.y, at.1)
&& near(to.w, *w)
&& near(to.h, *h)
&& to.flip_h == float.flip_h
&& to.flip_v == float.flip_v
&& float.rot == 0.0
{
return false;
}
let clamp = |v: f32| wordcraft_geom::finite(v).clamp(-MAX_OFFSET, MAX_OFFSET);
// An aligned position becomes an offset from the column / paragraph (where `origin` is).
if float.h_align.take().is_some() {
float.h_rel = Anchor::Column;
float.x = clamp(to.x - origin.0);
} else {
float.x = clamp(float.x + to.x - at.0);
}
if float.v_align.take().is_some() {
float.v_rel = Anchor::Paragraph;
float.y = clamp(to.y - origin.1);
} else {
float.y = clamp(float.y + to.y - at.1);
}
*w = to.w.clamp(0.0, MAX_OFFSET);
*h = to.h.clamp(0.0, MAX_OFFSET);
float.rot = 0.0;
float.flip_h = to.flip_h;
float.flip_v = to.flip_v;
para.touch();
true
}
/// A body position from `{"path": [..], "off": n}` (or any form `parse_pos` reads).
fn body_pos(e: &Value) -> Option<Pos> {
let path: Option<Vec<u32>> =
e.get("path").and_then(Value::as_array).map(|a| a.iter().take(64).filter_map(Value::as_u64).filter_map(|x| u32::try_from(x).ok()).collect());
match (path, p::u64(e, "off").and_then(|o| usize::try_from(o).ok())) {
(Some(path), Some(off)) if !path.is_empty() => Some(Pos { story: StoryRef::Body, path: wordcraft_doc::Path(path), off }),
_ => super::parse_pos(e).filter(|t| t.story == StoryRef::Body),
}
}
/// Give the drawing at `pos` a drawing id if it has none; returns it.
fn ensure_id(s: &mut Session, pos: &Pos) -> Result<u32, CmdError> {
let next = s.doc.next_shape_id();
let para = s.doc.para_mut(pos.story, &pos.path)?;
let (id, fresh) = match para.object_at_mut(pos.off) {
Some(InlineObject::Shape { extra, kind, .. }) if !kind.is_connector() => {
let fresh = extra.id == 0;
if fresh {
extra.id = next;
}
(extra.id, fresh)
}
_ => return Err(CmdError::Params("connectors glue to shapes and text boxes".into())),
};
if fresh {
para.touch();
}
Ok(id)
}
/// `shape.connect`: glue, move or unglue the selected connector's ends.
fn connect(s: &mut Session, v: &Value) -> CmdResult {
let (pos, obj) = super::objects::selected(s).ok_or_else(|| CmdError::Disabled("select a connector first".into()))?;
let InlineObject::Shape { kind, float, .. } = &obj else { return Err(CmdError::Disabled("select a connector first".into())) };
if !kind.is_connector() {
return Err(CmdError::Disabled("select a connector first".into()));
}
if pos.story != StoryRef::Body || float.wrap == Wrap::Inline {
return Err(CmdError::Disabled("only floating connectors in the body text can be glued".into()));
}
let (fh, fv) = (float.flip_h, float.flip_v);
let layout = s.layout();
let hit = layout.object(&pos, s.page_hint).ok_or_else(|| CmdError::Failed("the connector isn't laid out".into()))?;
let r = hit.rect;
let (mut p0, mut p1) = ends(r.x, r.y, r.w, r.h, fh, fv);
let mut glue: [Option<Option<ConnEnd>>; 2] = [None, None];
for (i, key) in ["start", "end"].into_iter().enumerate() {
let Some(e) = v.get(key) else { continue };
if e.is_null() {
glue[i] = Some(None);
continue;
}
let here = if i == 0 { p0 } else { p1 };
let (target, site) = if e.get("path").is_some() {
let at = body_pos(e).ok_or_else(|| CmdError::Params(format!("`{key}`: no shape there")))?;
let th = layout
.object(&at, hit.page)
.filter(|t| t.page == hit.page)
.ok_or_else(|| CmdError::Params(format!("`{key}`: no shape there on this page")))?;
let tkind = match s.doc.para_at(&at).and_then(|p| p.object_at(at.off)) {
Some(InlineObject::Shape { kind, .. }) if !kind.is_connector() => *kind,
_ => return Err(CmdError::Params(format!("`{key}`: connectors glue to shapes and text boxes"))),
};
let rect = [th.rect.x, th.rect.y, th.rect.w, th.rect.h];
let site = match p::u64(e, "site") {
Some(n) => u32::try_from(n)
.ok()
.filter(|n| site_point(tkind, rect, th.spin, *n).is_some())
.ok_or_else(|| CmdError::Params(format!("`{key}`: no such connection site")))?,
None => nearest_site(tkind, rect, th.spin, here).map(|(n, ..)| n).unwrap_or(0),
};
(Some(at), site)
} else {
let (x, y) = (p::f32(e, "x").ok_or_else(|| CmdError::Params(format!("`{key}`: a shape or a point")))?, p::f32(e, "y").unwrap_or(here.1));
let page = p::u64(e, "page").map_or(hit.page, |n| usize::try_from(n).unwrap_or(usize::MAX));
if page != hit.page {
return Err(CmdError::Params("a connector's ends stay on its page".into()));
}
// The nearest site of a shape (not a connector) within reach.
let mut best: Option<(Pos, u32, f32)> = None;
if let Some(pg) = layout.pages.get(page) {
for it in &pg.items {
let wordcraft_layout::Placed::Object { rect, spin, story: StoryRef::Body, path, off, .. } = it else { continue };
let at = Pos { story: StoryRef::Body, path: path.clone(), off: *off };
if at == pos {
continue;
}
let Some(InlineObject::Shape { kind, .. }) = s.doc.para_at(&at).and_then(|p| p.object_at(at.off)) else { continue };
if kind.is_connector() {
continue;
}
if let Some((n, _, d)) = nearest_site(*kind, [rect.x, rect.y, rect.w, rect.h], *spin, (x, y))
&& d <= SNAP
&& best.as_ref().is_none_or(|b| d < b.2)
{
best = Some((at, n, d));
}
}
}
match best {
Some((at, n, _)) => (Some(at), n),
None => {
// A free end, moved there.
if i == 0 {
p0 = (x, y);
} else {
p1 = (x, y);
}
glue[i] = Some(None);
continue;
}
}
};
if let Some(at) = target {
let id = ensure_id(s, &at)?;
glue[i] = Some(Some(ConnEnd { id, site }));
}
}
// Free ends first, then glued ones are routed.
let para = s.doc.para_mut(pos.story, &pos.path)?;
if let Some(InlineObject::Shape { extra, .. }) = para.object_at_mut(pos.off) {
if let Some(g) = glue[0] {
extra.start = g;
}
if let Some(g) = glue[1] {
extra.end = g;
}
para.touch();
}
place(s, &pos, route(p0, p1), (r.x, r.y), (hit.origin.x, hit.origin.y));
s.touch();
reroute(s);
let o = s.doc.para_at(&pos).and_then(|p| p.object_at(pos.off)).cloned();
Ok(json!({"object": serde_json::to_value(o).unwrap_or(Value::Null)}))
}
#[cfg(test)]
mod tests {
use super::*;
use wordcraft_doc::connector::ShapeExtra;
use wordcraft_doc::para::{Float, GroupChild, ShapeKind};
fn shape(kind: ShapeKind, extra: ShapeExtra, float: Float) -> InlineObject {
InlineObject::Shape {
kind,
w: 40.0,
h: 20.0,
fill: None,
stroke: None,
stroke_width: 1.0,
float,
story: None,
freeform: None,
effects: Default::default(),
extra,
}
}
/// The per-case shape/geometry of one scaled-group reroute case.
struct ScaledGroupSpec {
w: f32,
h: f32,
kind: ShapeKind,
rot: f32,
flip_h: bool,
flip_v: bool,
}
impl ScaledGroupSpec {
fn new(w: f32, h: f32, kind: ShapeKind, rot: f32, flip_h: bool, flip_v: bool) -> Self {
Self { w, h, kind, rot, flip_h, flip_v }
}
}
fn visual_ends(group: &InlineObject) -> ((f32, f32), (f32, f32)) {
let InlineObject::Group { w, h, float, .. } = group else { panic!("group") };
let rects = group.group_rects(0.0, 0.0, *w, *h);
let ([x, y, w, h], obj) = rects[1];
let own = obj.frame().unwrap().2.spin();
let outer = float.spin();
let turn = |(x, y)| {
let (x, y) = own.apply(rects[1].0[0] + w / 2.0, rects[1].0[1] + h / 2.0, x, y);
let InlineObject::Group { w, h, .. } = group else { panic!("group") };
outer.apply(*w / 2.0, *h / 2.0, x, y)
};
(turn((x, y)), turn((x + w, y + h)))
}
/// One scaled-group case: rerouting keeps the visual free end and the resolved sites.
fn assert_scaled_group_reroute(spec: ScaledGroupSpec, free: Option<ConnEnd>, free_start: bool) {
let attached = Some(ConnEnd { id: 7, site: 3 });
let (start, end) = if free_start { (free, attached) } else { (attached, free) };
let group = InlineObject::Group {
w: spec.w,
h: spec.h,
ch_w: 200.0,
ch_h: 300.0,
float: Float { rot: 25.0, flip_h: true, ..Default::default() },
canvas: None,
children: vec![
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, ShapeExtra { id: 7, ..Default::default() }, Float::default()) },
GroupChild {
x: 10.0,
y: 30.0,
obj: shape(
spec.kind,
ShapeExtra { start, end, ..Default::default() },
Float { rot: spec.rot, flip_h: spec.flip_h, flip_v: spec.flip_v, ..Default::default() },
),
},
],
};
// Rendering scales each child rectangle before applying its own spin.
let before = visual_ends(&group);
let expected = if free_start { before.0 } else { before.1 };
let mut s = Session::new(wordcraft_doc::Document::new());
let pos = s.doc.start_of(StoryRef::Body);
s.doc.para_mut(pos.story, &pos.path).unwrap().insert_object(pos.off, group, &Default::default()).unwrap();
assert!(reroute(&mut s));
let group = s.doc.para_at(&pos).unwrap().object_at(pos.off).unwrap();
let after = visual_ends(group);
let actual = if free_start { after.0 } else { after.1 };
assert!(
(actual.0 - expected.0).abs() < 0.001 && (actual.1 - expected.1).abs() < 0.001,
"{}x{}, {:?}, {}, {}, {}, {}: {:?} != {:?}",
spec.w,
spec.h,
spec.kind,
spec.rot,
spec.flip_h,
spec.flip_v,
free_start,
actual,
expected
);
let InlineObject::Group { children, .. } = group else { panic!("group") };
let c = &children[1];
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!("connector") };
let routed = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
assert_eq!(if free_start { routed.1 } else { routed.0 }, (140.0, 210.0), "resolved sites stay in child coordinates");
assert_eq!(float.rot, 0.0);
assert!(!reroute(&mut s), "rerouting must settle");
}
#[test]
fn rerouting_scaled_group_preserves_visual_free_and_unresolved_ends() {
for (w, h) in [(200.0, 300.0), (400.0, 600.0), (400.0, 150.0)] {
for kind in [ShapeKind::StraightConnector, ShapeKind::ElbowConnector, ShapeKind::CurvedConnector] {
for rot in [30.0, 90.0] {
for (flip_h, flip_v) in [(false, false), (true, false), (false, true), (true, true)] {
for free in [None, Some(ConnEnd { id: 404, site: 0 }), Some(ConnEnd { id: 7, site: 99 })] {
for free_start in [false, true] {
assert_scaled_group_reroute(ScaledGroupSpec::new(w, h, kind, rot, flip_h, flip_v), free, free_start);
}
}
}
}
}
}
}
}