Files
figcraft/crates/engine/src/cmd/structure.rs
T
BFlatandClaude Opus 5.5 71c467879d M2: auto layout nesting: one-pass nested hug/fill, coherent sizing, drag across nested frames (#45)
Layout (crates/layout):
- Fill children take their size before the frame is sized, so wrapping fill
  text, frames that hug it and grid rows settle in a single pass (three
  levels deep included).
- Fill children share space like CSS flexbox: min and max violations are
  resolved in turn instead of freezing both at once.
- A single child with space-between sits at the start; a wrapped row of only
  fill-height children keeps their height; grids size fill-width children
  before their rows.
- coherent_sizing: Fill only in an auto layout flow on an axis the parent
  doesn't hug, Hug only on auto layout frames and text (a text hugs what its
  resize mode grows); anything else becomes Fixed at its size.

Engine:
- Setting a child to Fill turns a hugging parent Fixed on that axis; a parent
  that starts to hug, layers leaving auto layout and layout.remove fix the
  rest (applied in normalize).
- Moving layers: the deepest auto layout frame under the pointer wins (plain
  frames in a flow are items, not destinations; the dragged layers and their
  descendants are skipped); the frame they come from keeps them near its
  edge. They take the slot (or grid cell) there with the insertion line drawn
  in the gap and the receiving frame outlined; out of auto layout they become
  positioned children of the canvas or plain frame at the drop point. One
  undo step per drag. Slots skip hidden and absolute children.

Tests: crates/layout/src/nested_tests.rs, crates/engine/src/al_nesting_tests.rs.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:41:11 +07:00

534 lines
23 KiB
Rust

//! Structure: groups, frames, masks, boolean groups, flatten, outline stroke, auto layout, pages.
use figcraft_color::{Color, Paint};
use figcraft_doc::*;
use figcraft_geom::{Affine, BezPath, Rect, Shape};
use serde_json::{Value, json};
use super::edit::{roots, z_sorted};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{EngineError, Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Group)),
spec!("object.ungroup", "Ungroup selection", "{ids?}", true, ungroup),
spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Frame)),
spec!("object.removeFrame", "Remove frame", "{ids?}", true, ungroup),
spec!(
"object.resizeToFit",
"Resize to fit",
"{ids?} (frames, sections and components shrink or grow to their contents; the contents stay put)",
true,
resize_to_fit
),
spec!("object.mask", "Use as mask", "{ids?}", true, mask),
spec!("object.releaseMask", "Release mask", "{ids?}", true, |s, p| {
for i in req_targets("object.releaseMask", s, p)? {
s.doc_mut()?.get_mut(i)?.is_mask = false;
}
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.flatten", "Flatten boolean", "{ids?}", true, flatten),
spec!("vector.flatten", "Flatten", "{ids?}", true, flatten),
spec!("vector.outlineStroke", "Outline stroke", "{ids?}", true, outline_stroke),
spec!("layout.add", "Add auto layout", "{ids?, direction? (horizontal|vertical|grid)}", true, layout_add),
spec!("layout.remove", "Remove auto layout", "{ids?}", true, |s, p| {
for i in req_targets("layout.remove", s, p)? {
// Hugging needs auto layout and the children that filled have nothing to fill: both
// become fixed (the frame keeps filling its own auto layout parent).
let doc = s.doc_mut()?;
doc.get_mut(i)?.layout = None;
figcraft_layout::coherent_sizing(doc, i);
}
ok()
}),
spec!("layout.suggest", "Suggest auto layout", "{ids?}", true, layout_add),
spec!("object.moveToPage", "Move to page", "{ids?, page}", true, move_to_page),
spec!("object.setThumbnail", "Set as thumbnail", "{id?}", true, |s, p| {
let id = targets(s, p).first().copied().ok_or_else(|| bad("object.setThumbnail", "select a frame"))?;
let doc = s.doc_mut()?;
for n in doc.ids().collect::<Vec<_>>() {
if let Some(node) = doc.node(n)
&& node.plugin_data.contains_key("figcraft.thumbnail")
{
doc.get_mut(n)?.plugin_data.remove("figcraft.thumbnail");
}
}
doc.get_mut(id)?.plugin_data.insert("figcraft.thumbnail".into(), "true".into());
ok()
}),
spec!("object.rasterize", "Rasterize selection", "{ids?, scale? (default 2)}", true, rasterize),
spec!("vector.reverse", "Reverse path direction", "{ids?}", true, |s, p| {
for i in req_targets("vector.reverse", s, p)? {
if let NodeKind::Vector { network } = &mut s.doc_mut()?.get_mut(i)?.kind {
for seg in &mut network.segments {
std::mem::swap(&mut seg.a, &mut seg.b);
std::mem::swap(&mut seg.ta, &mut seg.tb);
}
}
}
ok()
}),
spec!("vector.simplify", "Simplify vector", "{ids?, tolerance?}", true, simplify),
spec!("vector.offset", "Offset path", "{ids?, offset}", true, offset_path),
spec!("vector.join", "Join selection", "{ids?} (in vector edit mode: joins the selected points)", true, |s, p| {
crate::vectoredit::join_in_edit_mode(s, false).unwrap_or_else(|| join(s, p))
}),
spec!("vector.smoothJoin", "Smooth join selection", "{ids?} (in vector edit mode: joins the selected points smoothly)", true, |s, p| {
crate::vectoredit::join_in_edit_mode(s, true).unwrap_or_else(|| join(s, p))
}),
]
}
enum Wrap {
Group,
Frame,
Bool(BoolOp),
}
/// 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)?;
let doc = s.doc_mut()?;
let ids = z_sorted(doc, &roots(doc, &ids));
let top = *ids.last().ok_or_else(|| bad("object.group", "nothing selected"))?;
let parent = doc.get(top)?.parent.ok_or_else(|| bad("object.group", "can't group a page"))?;
let index = doc.index_in_parent(top).map(|i| i + 1);
let bounds = doc.bounds_of(&ids).unwrap_or_default();
let pw = doc.world_transform(parent);
let local = figcraft_geom::transform_rect_bbox(pw.inverse(), bounds);
let (kind, base) = match &w {
Wrap::Group => (NodeKind::Group, "Group"),
Wrap::Frame => (NodeKind::Frame, "Frame"),
Wrap::Bool(op) => (NodeKind::BooleanOp { op: *op }, op.label()),
};
let name = string(p, "name").unwrap_or_else(|| if matches!(w, Wrap::Bool(_)) { base.to_string() } else { doc.next_name(base) });
let gid = doc.alloc_id();
let mut g = Node::new(gid, kind, name, local.width(), local.height());
g.transform = Affine::translate((local.x0, local.y0));
if let Wrap::Bool(_) = w {
// A boolean group takes its look from the bottom-most shape.
if let Some(first) = ids.first().and_then(|i| doc.node(*i)) {
g.fills = if first.fills.is_empty() { vec![Paint::solid(Color::DEFAULT_SHAPE)] } else { first.fills.clone() };
g.strokes = first.strokes.clone();
g.stroke = first.stroke.clone();
g.effects = first.effects.clone();
}
g.blend = figcraft_color::BlendMode::Normal;
}
if let Wrap::Frame = w {
g.clip = true;
}
// Insert the container where the topmost layer is.
doc.insert(g, parent, index)?;
for i in &ids {
doc.reparent(*i, gid, None)?;
}
if !matches!(w, Wrap::Frame) {
let _ = doc.fit_to_children(gid);
}
s.set_selection(vec![gid]);
Ok(json!({"id": gid}))
}
fn ungroup(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("object.ungroup", s, p)?;
let doc = s.doc_mut()?;
let mut out = Vec::new();
for g in ids {
let n = doc.get(g)?;
if !(matches!(n.kind, NodeKind::Group | NodeKind::BooleanOp { .. } | NodeKind::Frame | NodeKind::Section { .. })) {
continue;
}
let Some(parent) = n.parent else { continue };
let idx = doc.index_in_parent(g).unwrap_or(0);
let kids = n.children.clone();
// Ungrouping a group keeps its opacity on the children (multiplied).
let op = n.opacity;
for (k, c) in kids.iter().enumerate() {
doc.reparent(*c, parent, Some(idx + k))?;
if op < 1.0 {
let cn = doc.get_mut(*c)?;
cn.opacity *= op;
}
out.push(*c);
}
doc.remove(g)?;
}
s.set_selection(out.clone());
Ok(json!({"ids": out}))
}
fn mask(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("object.mask", s, p)?;
if ids.len() == 1 {
let doc = s.doc_mut()?;
let id = ids.first().copied().unwrap_or(NodeId(0));
let n = doc.get_mut(id)?;
n.is_mask = !n.is_mask;
return ok();
}
// Group, then the bottom-most child is the mask.
let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), Wrap::Group)?;
let gid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("group failed".into()))?;
let doc = s.doc_mut()?;
if let Some(first) = doc.get(gid)?.children.first().copied() {
doc.get_mut(first)?.is_mask = true;
}
Ok(r)
}
/// The outline of a node in its parent's space (shapes, text, booleans, groups).
pub fn outline_in_parent(doc: &Document, id: NodeId) -> Option<BezPath> {
let n = doc.node(id)?;
let (mut p, _) = figcraft_render::subtree_outline(doc, n, 0)?;
p.apply_affine(n.transform);
Some(p)
}
fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("vector.flatten", s, p)?;
let doc = s.doc_mut()?;
let ids = z_sorted(doc, &roots(doc, &ids));
let top = *ids.last().ok_or_else(|| bad("vector.flatten", "nothing selected"))?;
let parent = doc.get(top)?.parent.ok_or_else(|| bad("vector.flatten", "can't flatten a page"))?;
let index = doc.index_in_parent(top).map(|i| i + 1);
let pw = doc.world_transform(parent);
let mut path = BezPath::new();
let mut even = false;
for i in &ids {
let Some(n) = doc.node(*i) else { continue };
// Booleans flatten to their result; strokes don't get outlined.
let Some((mut op, rule)) = (match &n.kind {
NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => figcraft_render::subtree_outline(doc, n, 0),
_ => figcraft_render::node_outline(doc, n),
}) else {
continue;
};
even |= rule == figcraft_render::FillRule::EvenOdd;
// Into the common parent's space.
let w = doc.world_transform(*i);
op.apply_affine(pw.inverse() * w);
path.extend(op.elements().iter().copied());
}
if path.elements().is_empty() {
return Err(EngineError::Disabled("vector.flatten".into(), "nothing to flatten".into()));
}
let first = ids.first().and_then(|i| doc.node(*i)).cloned();
let bb = path.bounding_box();
path.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
let mut net = VectorNetwork::from_path(&path);
for r in &mut net.regions {
r.winding = if even { WindingRule::EvenOdd } else { WindingRule::NonZero };
}
let vid = doc.alloc_id();
let name = first.as_ref().map(|f| f.name.clone()).unwrap_or_else(|| "Vector".into());
let mut v = Node::new(vid, NodeKind::Vector { network: net }, name, bb.width(), bb.height());
v.transform = Affine::translate((bb.x0, bb.y0));
if let Some(f) = first {
v.fills = if matches!(f.kind, NodeKind::Group | NodeKind::Line) && f.fills.is_empty() {
vec![Paint::solid(Color::DEFAULT_SHAPE)]
} else {
f.fills.clone()
};
v.strokes = f.strokes.clone();
v.stroke = StrokeProps { align: StrokeAlign::Center, ..f.stroke.clone() };
v.effects = f.effects.clone();
v.opacity = f.opacity;
v.blend = if matches!(f.blend, figcraft_color::BlendMode::PassThrough) { figcraft_color::BlendMode::Normal } else { f.blend };
}
doc.insert(v, parent, index)?;
for i in &ids {
doc.remove(*i)?;
}
s.set_selection(vec![vid]);
Ok(json!({"id": vid}))
}
fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("vector.outlineStroke", s, p)?;
let doc = s.doc_mut()?;
let mut out = Vec::new();
for i in ids {
let n = doc.get(i)?.clone();
if !n.has_visible_stroke() {
continue;
}
let closed = figcraft_doc::geometry::is_closed(&n);
let Some(geom) = (match &n.kind {
NodeKind::Text(_) | NodeKind::BooleanOp { .. } => figcraft_render::node_outline(doc, &n).map(|(p, _)| p),
_ => figcraft_doc::geometry::stroke_geometry(&n),
}) else {
continue;
};
let area = figcraft_render::draw::stroke_area(&n, &geom, closed);
let mut path = (*area).clone();
// Union the pieces so the outline is one clean shape.
if let Ok(u) = figcraft_pathops::simplify(&path) {
path = u;
}
let bb = path.bounding_box();
path.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
let mut net = VectorNetwork::from_path(&path);
for r in &mut net.regions {
r.winding = WindingRule::EvenOdd;
}
let parent = n.parent.ok_or_else(|| bad("vector.outlineStroke", "page"))?;
let idx = doc.index_in_parent(i);
let vid = doc.alloc_id();
let mut v = Node::new(vid, NodeKind::Vector { network: net }, n.name.clone(), bb.width(), bb.height());
v.transform = n.transform * Affine::translate((bb.x0, bb.y0));
v.fills = n.strokes.clone();
v.strokes.clear();
if n.fills.iter().any(|f| f.visible) && closed {
// Keep the fill as its own layer under the outline, grouped.
doc.insert(v, parent, idx.map(|x| x + 1))?;
let fill_id = i;
{
let f = doc.get_mut(fill_id)?;
f.strokes.clear();
}
let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), Wrap::Group)?;
if let Some(g) = id_of(&r["id"]) {
out.push(g);
}
return Ok(json!({"ids": out}));
}
doc.insert(v, parent, idx.map(|x| x + 1))?;
doc.remove(i)?;
out.push(vid);
}
s.set_selection(out.clone());
Ok(json!({"ids": out}))
}
/// Infer direction, gap and padding from where children are (Figma's "Add auto layout").
fn infer_layout(doc: &Document, frame: NodeId) -> AutoLayout {
let Some(f) = doc.node(frame) else { return AutoLayout::default() };
let boxes: Vec<Rect> =
f.children.iter().filter_map(|c| doc.node(*c)).map(|c| figcraft_geom::transform_rect_bbox(c.transform, c.local_rect())).collect();
if boxes.is_empty() {
return AutoLayout { padding: [10.0; 4], gap: 10.0, ..Default::default() };
}
let span_x = boxes.iter().map(|b| b.x1).fold(f64::MIN, f64::max) - boxes.iter().map(|b| b.x0).fold(f64::MAX, f64::min);
let span_y = boxes.iter().map(|b| b.y1).fold(f64::MIN, f64::max) - boxes.iter().map(|b| b.y0).fold(f64::MAX, f64::min);
let vertical = span_y > span_x;
let mut sorted = boxes.clone();
sorted.sort_by(|a, b| if vertical { a.y0.total_cmp(&b.y0) } else { a.x0.total_cmp(&b.x0) });
let gaps: Vec<f64> =
sorted.windows(2).filter_map(|w| Some(if vertical { w.get(1)?.y0 - w.first()?.y1 } else { w.get(1)?.x0 - w.first()?.x1 })).collect();
let gap = if gaps.is_empty() { 10.0 } else { (gaps.iter().sum::<f64>() / gaps.len() as f64).max(0.0).round() };
let all = boxes.iter().copied().reduce(|a, b| a.union(b)).unwrap_or_default();
let pad = [all.y0.max(0.0), (f.width - all.x1).max(0.0), (f.height - all.y1).max(0.0), all.x0.max(0.0)].map(f64::round);
AutoLayout { mode: if vertical { LayoutMode::Vertical } else { LayoutMode::Horizontal }, gap, padding: pad, ..Default::default() }
}
fn layout_add(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("layout.add", s, p)?;
let dir = string(p, "direction");
let doc = s.doc()?;
let all_frames = ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()));
let targets = if all_frames {
ids
} else {
// Wrap non-frames in a new frame without a fill, then add auto layout to it.
let r = wrap(s, &json!({"ids": ids}), Wrap::Frame)?;
let fid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("wrap failed".into()))?;
let f = s.doc_mut()?.get_mut(fid)?;
f.fills.clear();
f.clip = false;
vec![fid]
};
let doc = s.doc_mut()?;
for f in &targets {
let mut al = infer_layout(doc, *f);
if let Some(d) = &dir {
al.mode = match d.to_ascii_lowercase().as_str() {
"vertical" | "column" => LayoutMode::Vertical,
"grid" => LayoutMode::Grid,
_ => LayoutMode::Horizontal,
};
}
// Children in reading order along the axis.
let mut kids = doc.get(*f)?.children.clone();
let vertical = al.mode == LayoutMode::Vertical;
kids.sort_by(|a, b| {
let pa = doc.node(*a).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default();
let pb = doc.node(*b).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default();
if vertical { pa.y0.total_cmp(&pb.y0) } else { pa.x0.total_cmp(&pb.x0) }
});
let n = doc.get_mut(*f)?;
n.children = kids;
n.layout = Some(al);
n.child_layout.sizing_h = Sizing::Hug;
n.child_layout.sizing_v = Sizing::Hug;
}
s.set_selection(targets.clone());
Ok(json!({"ids": targets}))
}
fn move_to_page(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("object.moveToPage", s, p)?;
let page = req_id("object.moveToPage", p, "page")?;
let doc = s.doc_mut()?;
if !doc.node(page).is_some_and(Node::is_page) {
return Err(bad("object.moveToPage", format!("{page} is not a page")));
}
for i in z_sorted(doc, &roots(doc, &ids)) {
doc.reparent(i, page, None)?;
}
s.set_selection(vec![]);
ok()
}
fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("object.rasterize", s, p)?;
let scale = num_or(p, "scale", 2.0);
let mut out = Vec::new();
for i in ids {
let doc = s.doc()?;
let Some(pix) = figcraft_render::render_node(doc, i, scale) else { continue };
let png = figcraft_render::encode_png(&pix).map_err(EngineError::Other)?;
let b = figcraft_render::export_bounds(doc, i).unwrap_or_default();
let parent = doc.get(i)?.parent.ok_or_else(|| bad("object.rasterize", "page"))?;
let idx = doc.index_in_parent(i);
let pw = doc.world_transform(parent);
let name = doc.get(i)?.name.clone();
let docm = s.doc_mut()?;
let (hash, _, _) = figcraft_format::add_image(docm, png).map_err(|e| EngineError::Other(e.to_string()))?;
let rid = docm.alloc_id();
let mut r = Node::new(rid, NodeKind::Rectangle, name, b.width(), b.height());
r.transform = pw.inverse() * Affine::translate((b.x0, b.y0));
r.fills = vec![Paint::image(hash, figcraft_color::ImageScaleMode::Fill)];
docm.insert(r, parent, idx.map(|x| x + 1))?;
docm.remove(i)?;
out.push(rid);
}
s.set_selection(out.clone());
Ok(json!({"ids": out}))
}
fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
let tol = num_or(p, "tolerance", 0.5).max(0.01);
for i in req_targets("vector.simplify", s, p)? {
let doc = s.doc_mut()?;
let n = doc.get_mut(i)?;
if let NodeKind::Vector { network } = &mut n.kind {
let fill = network.fill_path();
let src = if fill.elements().is_empty() { network.stroke_path() } else { fill };
let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(src.iter(), tol, &Default::default());
let regions = !network.regions.is_empty();
let mut net = VectorNetwork::from_path(&simplified);
if !regions {
net.regions.clear();
}
*network = net;
}
}
ok()
}
fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
let d = req_num("vector.offset", p, "offset")?;
for i in req_targets("vector.offset", s, p)? {
let doc = s.doc_mut()?;
let n = doc.get(i)?.clone();
let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue };
let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() };
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
let res = if d >= 0.0 {
figcraft_pathops::boolean(&[path, ring], BoolOp::Union)
} else {
figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract)
};
let Ok(mut res) = res else { continue };
let bb = res.bounding_box();
res.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
let nm = doc.get_mut(i)?;
let mut net = VectorNetwork::from_path(&res);
for r in &mut net.regions {
r.winding = WindingRule::EvenOdd;
}
nm.kind = NodeKind::Vector { network: net };
nm.transform *= Affine::translate((bb.x0, bb.y0));
nm.width = bb.width();
nm.height = bb.height();
}
ok()
}
/// Join open vectors whose end points touch into one vector.
fn join(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("vector.join", s, p)?;
if ids.len() < 2 {
// Close a single open path.
if let Some(i) = ids.first() {
let n = s.doc_mut()?.get_mut(*i)?;
if let NodeKind::Vector { network } = &mut n.kind
&& network.regions.is_empty()
&& network.vertices.len() > 2
{
let last = network.vertices.len() - 1;
network.segments.push(Segment::line(last, 0));
let all: Vec<usize> = (0..network.segments.len()).collect();
network.regions.push(Region { loops: vec![all], winding: WindingRule::NonZero });
}
}
return ok();
}
flatten(s, &json!({"ids": ids}))
}
/// Fit containers to their visible children's bounds, keeping the children where they are on the
/// page (auto layout frames hug instead); rotated containers fit in their own axes.
fn resize_to_fit(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "object.resizeToFit";
let ids = req_targets(cmd, s, p)?;
let doc = s.doc_mut()?;
let mut fitted = Vec::new();
for id in ids {
let Some(n) = doc.node(id).cloned() else { continue };
let container = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. });
if !container || n.children.is_empty() || matches!(n.kind, NodeKind::Instance { .. }) {
continue;
}
// Auto layout frames fit by hugging their contents.
if n.layout.is_some() {
let node = doc.get_mut(id)?;
node.child_layout.sizing_h = Sizing::Hug;
node.child_layout.sizing_v = Sizing::Hug;
fitted.push(id);
continue;
}
// Children's bounds in the container's own coordinates.
let mut b: Option<figcraft_geom::Rect> = None;
for c in n.children.iter().filter_map(|c| doc.node(*c)).filter(|c| c.visible) {
let r = figcraft_geom::transform_rect_bbox(c.transform, c.local_rect());
if r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite() {
b = Some(b.map_or(r, |u| u.union(r)));
}
}
let Some(b) = b else { continue };
let (w, h) = (b.width().max(0.01), b.height().max(0.01));
// Move the container by the offset, and its children back by it.
let shift = Affine::translate((b.x0, b.y0));
let node = doc.get_mut(id)?;
node.transform *= shift;
node.width = w;
node.height = h;
for c in &n.children {
if let Ok(cn) = doc.get_mut(*c) {
cn.transform = shift.inverse() * cn.transform;
}
}
fitted.push(id);
}
Ok(json!({"ids": fitted}))
}