From e0e57dd42a65bc2d42874e3cb459075806225a08 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Fri, 13 Nov 2026 00:45:11 -0500 Subject: [PATCH] M12: a mind map in a section or frame without a place is centred on the view board.mindMap's default x and y came from the view's centre, which is on the page, but x and y are the root's place in its parent: under a section at (1000, 500) the map landed 1000 right and 500 down of the view's centre. The default now takes the centre through the parent's inverse world transform (a parent that can't be inverted gives a non-finite centre, which the coordinate check refuses). Explicit x and y stay parent-local. Tests: two in board_mindmap_tests (a section; a frame turned 30 degrees, with a given place carried through the frame). Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- crates/engine/src/board_mindmap_tests.rs | 38 +++++++++++++++++++++++ crates/engine/src/boards/board/mindmap.rs | 8 +++-- docs/mcp.md | 2 +- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/board_mindmap_tests.rs b/crates/engine/src/board_mindmap_tests.rs index 19bb459ae..fa717b8eb 100644 --- a/crates/engine/src/board_mindmap_tests.rs +++ b/crates/engine/src/board_mindmap_tests.rs @@ -383,3 +383,41 @@ fn the_caps_refuse_what_is_too_far_and_cut_what_is_too_long() { assert_eq!(text_data(&s, id(&out, "id")).characters.chars().count(), 500); assert_eq!(text_data(&s, id(&out["children"][0], "id")).characters.chars().count(), 500); } + +/// Where a node's own origin (the place `x`/`y` name) is on the page. +fn origin(s: &Session, n: NodeId) -> Point { + s.doc().unwrap().world_transform(n) * Point::new(0.0, 0.0) +} + +#[test] +fn a_mind_map_in_a_section_without_a_place_is_centred_on_the_view() { + let mut s = board(); + let (vx, vy) = { + let v = &s.ds().unwrap().view; + (v.cx, v.cy) + }; + let sec = id(&s.execute("node.create", &json!({"type": "section", "x": 1000, "y": 500, "width": 4000, "height": 4000})).unwrap(), "id"); + let out = s.execute("board.mindMap", &json!({"parent": sec})).unwrap(); + let c = origin(&s, id(&out, "id")); + assert!(same(c.x, vx) && same(c.y, vy), "the root's place {c:?} is the view's centre ({vx}, {vy})"); +} + +#[test] +fn a_mind_map_in_a_turned_frame_without_a_place_is_centred_on_the_view_and_a_given_place_is_the_frames() { + let mut s = board(); + let (vx, vy) = { + let v = &s.ds().unwrap().view; + (v.cx, v.cy) + }; + let fr = id(&s.execute("node.create", &json!({"type": "frame", "x": 300, "y": 200, "width": 2000, "height": 2000})).unwrap(), "id"); + s.execute("node.set", &json!({"id": fr, "props": {"rotation": 30}})).unwrap(); + let out = s.execute("board.mindMap", &json!({"parent": fr})).unwrap(); + let c = origin(&s, id(&out, "id")); + assert!(same(c.x, vx) && same(c.y, vy), "the root's place {c:?} is the view's centre ({vx}, {vy})"); + + // A given place is the frame's own, as on the page: (10, 20) of the frame, carried through it. + let given = id(&s.execute("board.mindMap", &json!({"parent": fr, "x": 10, "y": 20})).unwrap(), "id"); + let want = s.doc().unwrap().world_transform(fr) * Point::new(10.0, 20.0); + let got = origin(&s, given); + assert!(same(got.x, want.x) && same(got.y, want.y), "{got:?} against {want:?}"); +} diff --git a/crates/engine/src/boards/board/mindmap.rs b/crates/engine/src/boards/board/mindmap.rs index 5e2cff1ce..656a3fa14 100644 --- a/crates/engine/src/boards/board/mindmap.rs +++ b/crates/engine/src/boards/board/mindmap.rs @@ -168,9 +168,13 @@ pub(super) fn mind_map_cmd(s: &mut Session, p: &Value) -> Result { let cmd = "board.mindMap"; require(s, cmd)?; let parent = parent_of(cmd, s, p)?; + // The view's centre is on the page, and `x`/`y` are in the parent: a map in a section or frame + // takes the centre in that parent's coordinates (a parent that can't be inverted gives a + // non-finite centre, which `coord` refuses). let (vx, vy) = { - let v = &s.ds()?.view; - (v.cx, v.cy) + let ds = s.ds()?; + let c = ds.doc.world_transform(parent).inverse() * figcraft_geom::Point::new(ds.view.cx, ds.view.cy); + (c.x, c.y) }; let (x, y) = (coord(cmd, p, "x", vx)?, coord(cmd, p, "y", vy)?); let texts = starters(cmd, p)?; diff --git a/docs/mcp.md b/docs/mcp.md index 21dfe8ca9..8d03540ba 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -498,7 +498,7 @@ one undo step and returns the ids it made: in the app does both). - `board.mindMap {x?, y?, text?, children?, parent?, edit?}` → `{id, text, children: [{id, text, connector}]}`: a mind map: a first node (`mindNode`, bold 24, no box; `text` defaults to `Topic`, `x`/`y` to the - middle of the view) with up to 20 `children` (texts; default `Idea 1`, `Idea 2`, `Idea 3`) + view's centre, in the parent's coordinates) with up to 20 `children` (texts; default `Idea 1`, `Idea 2`, `Idea 3`) on its right, each joined to it by a curved branch (a connector with `branch: true`: stroke 4, `#757575`, round caps). The map lays itself out: children stand in one column 88 units right of the widest node of their parent's column, 48 apart, centred on their parent; a node is its text,