From b53cd7b52e9142ef1fcd8400998cb0b66bcac32c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 09:37:03 -0800 Subject: [PATCH] Envelope: type that draws nothing adds no box next to other content An envelope around type of only spaces and a rectangle mapped from the union of the rectangle and the type's laid-out box, so the empty type moved and scaled how the rectangle was distorted. Type that draws nothing now adds no box while other content in the envelope has one, in groups and layers and as a clipping path too. When nothing else in the envelope has a box, the type keeps its laid-out box, so an envelope of only empty type keeps its frame, Make with Mesh and Reset with Mesh still work, and Release still gives back the warp shape. This applies to Make with Warp, Make with Mesh, Make with Top Object, Reset with Mesh, Edit Contents and the stroke margins. Type that has not been measured yet keeps its laid-out box as before. The PDF/.ai import's boxes for envelopes with Distort Appearance measure each object as the whole contents measure it, so the margins the import tries match the contents' geometric box. The three import tests that measure type without registering the glyph measure now register it first, so another test registering it while they run no longer changes their result. --- crates/doc/src/live.rs | 4 +- crates/doc/src/live/envelope.rs | 63 +++++++++++++++++-------- crates/engine/src/tests/envelope.rs | 50 ++++++++++++++++++++ crates/eps/src/import/ai/envelope.rs | 69 ++++++++++++++++++++++++---- docs/target-app-parity.md | 2 +- 5 files changed, 155 insertions(+), 33 deletions(-) diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index cf7a4d2fa0..07cadc06f0 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -54,8 +54,8 @@ pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, use blend_spec::{blend_color_shift, blend_ramp}; pub use envelope::{ EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, TypeInk, envelope_bounds, envelope_content_bounds, - envelope_content_boxes, envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, envelope_overlay, envelope_stroke_margin, - set_type_ink, smooth_handles, warp_grid, + envelope_content_boxes, envelope_content_boxes_with, envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, + envelope_overlay, envelope_stroke_margin, set_type_ink, smooth_handles, warp_grid, }; pub(crate) use expand::max_outset; pub use expand::{ diff --git a/crates/doc/src/live/envelope.rs b/crates/doc/src/live/envelope.rs index 44dc406c1d..5aa43b0a67 100644 --- a/crates/doc/src/live/envelope.rs +++ b/crates/doc/src/live/envelope.rs @@ -149,20 +149,31 @@ impl EnvelopeOptions { /// its glyph outlines (see [`envelope_content_bounds`]). Strokes scale with the frame as /// [`Node::transform`] scales them. pub fn envelope_content_boxes(content: &[Arc], frame: Affine) -> Option<(Rect, Rect)> { + envelope_content_boxes_with(content, frame, false).or_else(|| envelope_content_boxes_with(content, frame, true)) +} + +/// [`envelope_content_boxes`] with type that draws nothing given its laid-out box when +/// `blank_type_box` is set and no box when it is not. A caller that measures the members of an +/// envelope's contents one at a time passes `true` only when the whole contents have no box with +/// `false`, so each member is measured as the whole contents measure it. +pub fn envelope_content_boxes_with(content: &[Arc], frame: Affine, blank_type_box: bool) -> Option<(Rect, Rect)> { let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY }; let to_frame = frame.inverse(); - let (stroked, plain) = content.iter().fold((None, None), |(stroked, plain), c| { - (vectorcraft_geom::union_opt(stroked, content_box(c, true, to_frame)), vectorcraft_geom::union_opt(plain, content_box(c, false, to_frame))) - }); - Some((stroked?, plain?)) + Some((content_set_box(content, true, to_frame, blank_type_box)?, content_set_box(content, false, to_frame, blank_type_box)?)) } /// The geometric bounds an envelope around `content` on the page axes maps from: type, alone or /// in groups and layers, is measured by its glyph outlines, underlines and strikethroughs instead /// of its laid-out box. Type keeps its laid-out box while nothing is registered with -/// [`set_type_ink`], and when it draws nothing. +/// [`set_type_ink`]. Type that draws nothing adds no box while other content has one; when +/// nothing else has a box, it keeps its laid-out box. pub fn envelope_content_bounds(content: &[Arc]) -> Option { - content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, false, Affine::IDENTITY))) + content_set_box(content, false, Affine::IDENTITY, false).or_else(|| content_set_box(content, false, Affine::IDENTITY, true)) +} + +/// The union of the boxes [`content_box`] gives each of `content`. +fn content_set_box(content: &[Arc], stroked: bool, to: Affine, blank_type_box: bool) -> Option { + content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, stroked, to, blank_type_box))) } /// The box of type's glyph outlines, underlines and strikethroughs with its text space mapped by @@ -367,15 +378,16 @@ fn find_type(n: &Node, typed: &mut std::collections::HashSet) -> bool { /// or with `stroked` its visual bounds (strokes scaled by `to` as [`Node::transform`] scales them), /// with type measured by its glyph outlines ([`type_ink`]) instead of its laid-out box, in groups /// and layers too. Type is laid out as it is on the page, and its outlines are then taken into -/// those axes. A character's strokes reach as far past its glyphs as past the laid-out box. -fn content_box(n: &Arc, stroked: bool, to: Affine) -> Option { +/// those axes. A character's strokes reach as far past its glyphs as past the laid-out box. Type +/// that draws nothing has its laid-out box with `blank_type_box`, and no box without it. +fn content_box(n: &Arc, stroked: bool, to: Affine, blank_type_box: bool) -> Option { let mut typed = std::collections::HashSet::new(); find_type(n, &mut typed); - content_box_in(n, stroked, to, &typed) + content_box_in(n, stroked, to, &typed, blank_type_box) } /// [`content_box`], with the addresses of the objects in `n` that hold type ([`find_type`]). -fn content_box_in(n: &Arc, stroked: bool, to: Affine, typed: &std::collections::HashSet) -> Option { +fn content_box_in(n: &Arc, stroked: bool, to: Affine, typed: &std::collections::HashSet, blank_type_box: bool) -> Option { // `n` in the axes of `to`. let moved = || { if to == Affine::IDENTITY { @@ -395,10 +407,13 @@ fn content_box_in(n: &Arc, stroked: bool, to: Affine, typed: &std::collect let ink = match n.projection() { None => type_ink(n, t, to * t.xf, None), Some(h) => type_ink(n, t, t.xf, Some((h, to))), - } - .flatten(); + }; let m = moved(); - let Some(ink) = ink else { return own(&m) }; + let ink = match ink { + Some(Some(ink)) => ink, + Some(None) if !blank_type_box => return None, + _ => return own(&m), + }; if !stroked { return Some(ink); } @@ -413,7 +428,13 @@ fn content_box_in(n: &Arc, stroked: bool, to: Affine, typed: &std::collect NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { // A clipping path that paints strokes over the clip edge, as in `Node::visual_bounds`. let clip = children.first()?; - content_box_in(clip, stroked && matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)), to, typed) + content_box_in( + clip, + stroked && matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)), + to, + typed, + blank_type_box, + ) } NodeKind::Layer { .. } | NodeKind::Group { .. } => { let layer = matches!(n.kind, NodeKind::Layer { .. }); @@ -424,9 +445,11 @@ fn content_box_in(n: &Arc, stroked: bool, to: Affine, typed: &std::collect let mut own_strokes = Node::new(n.id, NodeKind::Group { children: Vec::new(), clip: false }); own_strokes.appearance = n.appearance.clone(); own_strokes.transform(to, true); - own_strokes.visual_of_members(members.map(|c| content_box_in(c, true, to, typed))) + own_strokes.visual_of_members(members.map(|c| content_box_in(c, true, to, typed, blank_type_box))) } else { - members.filter(|c| c.visible || !layer).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box_in(c, false, to, typed))) + members + .filter(|c| c.visible || !layer) + .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box_in(c, false, to, typed, blank_type_box))) } } _ => own(&moved()), @@ -649,7 +672,7 @@ impl<'a> EnvelopeMap<'a> { pub fn new(content: &[Arc], kind: &'a EnvelopeKind, frame: Affine, options: EnvelopeOptions) -> Option { let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY }; let to_frame = frame.inverse(); - let src = content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, false, to_frame)))?; + let src = content_set_box(content, false, to_frame, false).or_else(|| content_set_box(content, false, to_frame, true))?; let src = options.source_box(src); Some(Self { src, frame, to_frame, surface: surface_of(kind, src, frame) }) } @@ -835,14 +858,14 @@ mod tests { let to = Affine::scale(2.0); let mut moved = (*group).clone(); moved.transform(to, true); - let want = moved.visual_of_members(std::iter::once(content_box(&text, true, to))); + let want = moved.visual_of_members(std::iter::once(content_box(&text, true, to, true))); assert!(want.is_some()); - assert_eq!(content_box(&group, true, to), want); + assert_eq!(content_box(&group, true, to, true), want); let mut nested = group; for i in 0..300 { nested = Arc::new(Node::group(NodeId(10 + i), vec![nested])); } - assert!(content_box(&nested, false, Affine::IDENTITY).is_some()); + assert!(content_box(&nested, false, Affine::IDENTITY, true).is_some()); } /// A warp envelope of `style` at `bend` with map `version` around a 200×100 rectangle at diff --git a/crates/engine/src/tests/envelope.rs b/crates/engine/src/tests/envelope.rs index 9d8999e388..055cc62740 100644 --- a/crates/engine/src/tests/envelope.rs +++ b/crates/engine/src/tests/envelope.rs @@ -792,6 +792,56 @@ fn an_envelope_of_type_that_draws_nothing_maps_from_its_laid_out_box() { assert_box(src_of(&s, e), laid, "warp"); } +/// Type that draws nothing adds no box next to other content: Make with Warp and Make with Mesh +/// around it and a rectangle, alone or in a group, map from the rectangle. +#[test] +fn type_that_draws_nothing_adds_no_box_next_to_other_content() { + for (cmd, params) in + [("object.envelope.makeWithWarp", json!({"style": "arc", "bend": 0})), ("object.envelope.makeWithMesh", json!({"rows": 1, "cols": 1}))] + { + for grouped in [false, true] { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 100.0); + let want = node(&s, a).geometric_bounds().unwrap(); + let t = id_of(&s.execute("text.create", &json!({"x": 400, "y": 450, "text": " ", "size": 96})).unwrap()); + let laid = node(&s, t).geometric_bounds().unwrap(); + assert!(laid.x1 > want.x1 && laid.y1 > want.y1, "the laid-out box {laid:?} reaches past {want:?}"); + sel(&mut s, &[a, t]); + if grouped { + s.execute("object.group", &json!({})).unwrap(); + } + let layer = s.doc().unwrap().doc.layers[0].clone(); + assert_box(live::envelope_content_bounds(&[layer]).unwrap(), want, "content bounds"); + let e = id_of(&s.execute(cmd, ¶ms).unwrap()); + assert_box(src_of(&s, e), want, &format!("{cmd}, grouped: {grouped}")); + } + } +} + +/// Deleting every character of enveloped type in Edit Contents leaves the envelope mapping from +/// the rectangle beside it; with nothing beside it, from the type's laid-out box. +#[test] +fn edit_contents_that_empties_type_maps_from_the_other_content() { + for with_rect in [true, false] { + let mut s = session(); + let a = with_rect.then(|| rect(&mut s, 100.0, 100.0, 200.0, 100.0)); + let rect_box = a.map(|a| node(&s, a).geometric_bounds().unwrap()); + let t = id_of(&s.execute("text.create", &json!({"x": 400, "y": 450, "text": "Type", "size": 96})).unwrap()); + sel(&mut s, &a.into_iter().chain([t]).collect::>()); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap()); + sel(&mut s, &[e]); + s.execute("object.envelope.editContents", &json!({"editing": true})).unwrap(); + s.execute("text.setText", &json!({"ids": [t.0], "text": " "})).unwrap(); + sel(&mut s, &[e]); + s.execute("object.envelope.editContents", &json!({"editing": false})).unwrap(); + let NodeKind::Envelope { content, .. } = node(&s, e).kind else { panic!("envelope") }; + let blank = content.iter().find(|c| c.id == t).expect("the type stays"); + assert!(matches!(&blank.kind, NodeKind::Text(tx) if tx.plain_text().trim().is_empty()), "the type was emptied"); + let want = rect_box.unwrap_or_else(|| blank.geometric_bounds().unwrap()); + assert_box(src_of(&s, e), want, &format!("with a rectangle: {with_rect}")); + } +} + /// The strikethrough bar runs to the last glyph's advance, past its outline: the envelope maps /// from it too. #[test] diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index 741ebfabdf..509a35a477 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -32,7 +32,7 @@ use std::sync::Arc; use kurbo::{Affine, BezPath, Point, Rect, Vec2}; use vectorcraft_doc::live::{ EnvelopeKind, EnvelopeOptions, GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, default_fidelity, envelope_content_boxes, - envelope_stroke_margin, + envelope_content_boxes_with, envelope_stroke_margin, }; use vectorcraft_doc::{Appearance, Document, Effect, ImageBlob, LayerColor, Node, NodeId, NodeKind, PatternDef}; use vectorcraft_geom::PathData; @@ -150,23 +150,31 @@ fn half_stroked_box(content: &[Arc]) -> Option { fn grown(r: Rect, by: f64) -> Rect { if by.is_finite() && by > 0.0 { r.inflate(by, by) } else { r } } - fn walk(n: &Arc, acc: Option) -> Option { + /// `n`'s box, with type that draws nothing given its laid-out box only with `blank`. + fn walk(n: &Arc, acc: Option, blank: bool) -> Option { let one = std::slice::from_ref(n); match &n.kind { NodeKind::Text(t) => { - let Some((_, ink)) = envelope_content_boxes(one, Affine::IDENTITY) else { return acc }; + let Some((_, ink)) = envelope_content_boxes_with(one, Affine::IDENTITY, blank) else { return acc }; // The characters' own strokes as the type's bounds with strokes measure them (they // have no miter allowance there), and the strokes of its appearance along a side. vectorcraft_geom::union_opt(acc, Some(grown(ink, t.char_stroke_outset().max(along(&n.appearance))))) } NodeKind::Group { children, clip: false } => { - let members = children.iter().fold(None, |acc, c| walk(c, acc)); + let members = children.iter().fold(None, |acc, c| walk(c, acc, blank)); vectorcraft_geom::union_opt(acc, members.map(|b| grown(b, along(&n.appearance)))) } - _ => vectorcraft_geom::union_opt(acc, envelope_content_boxes(one, Affine::IDENTITY).map(|(stroked, _)| stroked)), + _ => vectorcraft_geom::union_opt(acc, envelope_content_boxes_with(one, Affine::IDENTITY, blank).map(|(stroked, _)| stroked)), } } - content.iter().fold(None, |acc, c| walk(c, acc)) + let blank = blank_type_box(content); + content.iter().fold(None, |acc, c| walk(c, acc, blank)) +} + +/// Do the contents of an envelope give type that draws nothing its laid-out box: has nothing else +/// in `content` a box ([`envelope_content_boxes_with`])? +fn blank_type_box(content: &[Arc]) -> bool { + envelope_content_boxes_with(content, Affine::IDENTITY, false).is_none() } /// Envelope `kind` mapping from its contents' glyphs grown by as far as their strokes paint along a @@ -216,19 +224,24 @@ fn painted_outlines(outlines: &Node) -> Option { /// their members grown by as far as their own strokes paint along a side, other objects by their /// bounds with strokes. `None` when nothing in it has bounds. fn painted_box(content: &[Arc]) -> Option { - fn walk(n: &Arc, acc: Option) -> Option { + /// `n`'s box, with type that draws nothing given its laid-out box only with `blank`. + fn walk(n: &Arc, acc: Option, blank: bool) -> Option { match &n.kind { NodeKind::Text(_) => vectorcraft_geom::union_opt(acc, vectorcraft_effects::outline_text(n).as_ref().and_then(painted_outlines)), NodeKind::Group { children, clip: false } => { let along = n.appearance.painted_strokes().map(|s| s.side_reach(true)).fold(0.0, f64::max); - let members = children.iter().fold(None, |acc, c| walk(c, acc)); + let members = children.iter().fold(None, |acc, c| walk(c, acc, blank)); let grown = |r: Rect| if along.is_finite() && along > 0.0 { r.inflate(along, along) } else { r }; vectorcraft_geom::union_opt(acc, members.map(grown)) } - _ => vectorcraft_geom::union_opt(acc, envelope_content_boxes(std::slice::from_ref(n), Affine::IDENTITY).map(|(stroked, _)| stroked)), + _ => vectorcraft_geom::union_opt( + acc, + envelope_content_boxes_with(std::slice::from_ref(n), Affine::IDENTITY, blank).map(|(stroked, _)| stroked), + ), } } - content.iter().fold(None, |acc, c| walk(c, acc)) + let blank = blank_type_box(content); + content.iter().fold(None, |acc, c| walk(c, acc, blank)) } /// The bounds margin of `painted`, the box of what contents with geometric box `plain` paint, a side @@ -1019,6 +1032,7 @@ mod tests { /// miter join may at a corner. #[test] fn stroked_type_is_tried_mapped_from_its_glyphs_grown_by_half_its_stroke() { + vectorcraft_text::register_envelope_ink(); let mut text = type_node(1, CharStyle::default()); let mut appearance = (*text.appearance).clone(); appearance.items.push(AppearanceItem::Stroke(mitred())); @@ -1034,6 +1048,7 @@ mod tests { /// turned and stretched as well as upright. #[test] fn type_with_a_character_stroke_is_tried_mapped_from_its_bounds_with_strokes() { + vectorcraft_text::register_envelope_ink(); let style = CharStyle { stroke: Paint::solid(Color::BLACK), stroke_width: 4.0, ..CharStyle::default() }; for xf in [ Affine::IDENTITY, @@ -1053,6 +1068,7 @@ mod tests { /// A stroke on a group's own appearance round type grows the box by half its weight too. #[test] fn a_stroke_on_a_group_of_type_grows_the_box_by_half_its_weight() { + vectorcraft_text::register_envelope_ink(); let mut group = Node::new(NodeId(2), NodeKind::Group { children: vec![Arc::new(type_node(1, CharStyle::default()))], clip: false }); let mut appearance = (*group.appearance).clone(); appearance.items.push(AppearanceItem::Stroke(mitred())); @@ -1235,6 +1251,39 @@ mod tests { assert_eq!(painted_outlines(&outlines), Some(Rect::new(9.0, 9.0, 21.0, 21.0))); } + /// Type that draws nothing adds no box next to other content in the boxes the import tries an + /// envelope with Distort Appearance mapped from, loose and in a group, as it adds none to the + /// contents' geometric box: a rectangle with a round 4 point stroke beside type of only spaces + /// gives the rectangle's bounds with strokes. + #[test] + fn type_that_draws_nothing_adds_no_box_to_the_stroked_boxes() { + use kurbo::Shape; + vectorcraft_text::register_envelope_ink(); + let mut stroke = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0); + (stroke.join, stroke.cap) = (LineJoin::Round, LineCap::Round); + let mut appearance = Appearance::default(); + appearance.items.push(AppearanceItem::Stroke(stroke)); + let square = Arc::new(Node::path(NodeId(1), PathData::from_bezpath(&Rect::new(10.0, 10.0, 110.0, 60.0).to_path(0.1)), appearance)); + let blank = Node::new( + NodeId(2), + NodeKind::Text(Box::new(TextObject::point(Point::new(400.0, 450.0), " ", CharStyle { size: 96.0, ..CharStyle::default() }))), + ); + let laid = blank.geometric_bounds().unwrap(); + assert!(laid.x1 > 110.0 && laid.y1 > 60.0, "the laid-out box {laid:?} reaches past the rectangle"); + let want = Rect::new(8.0, 8.0, 112.0, 62.0); + let blank = Arc::new(blank); + for content in [vec![square.clone(), blank.clone()], vec![Arc::new(Node::group(NodeId(3), vec![square.clone(), blank.clone()]))]] { + let (_, plain) = envelope_content_boxes(&content, Affine::IDENTITY).unwrap(); + assert!(close(plain, Rect::new(10.0, 10.0, 110.0, 60.0), 1e-6), "{plain:?}"); + let half = half_stroked_box(&content).unwrap(); + assert!(close(half, want, 1e-6), "{half:?}"); + let painted = painted_box(&content).unwrap(); + assert!(close(painted, want, 1e-6), "{painted:?}"); + } + // With nothing else in the envelope, the type keeps its laid-out box. + assert_eq!(half_stroked_box(&[blank]), Some(laid)); + } + /// A side of what contents paint within a ten-thousandth of a point of their geometric box's is /// that side, inside it or out: the two boxes measure the same outline two ways. Further inside, /// there is no box; further out, it counts. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7044797d5e..47f0c8afed 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1780,7 +1780,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Type drawn as glyph outlines:** under a geometry effect, in EMF and WMF export, in DXF export when it is written as outlines, flattened with Convert All Text to Outlines, and inside an envelope, type keeps its underline and strikethrough, the weight and dashes of its character strokes when it is scaled, and the placement of its gradients and patterns, as the canvas draws it. A group's or layer's own fills and strokes also paint the underline and strikethrough of the type in it. -- **Envelopes of type map from the glyphs:** an envelope maps point type, area type, type in groups and layers and type used as a clipping path from the bounds of the glyph outlines, underlines and strikethroughs instead of the type's laid-out box (ascent to descent, or the area's frame), with inline graphics counted by their boxes; rotated type and type in perspective map from their outlines as drawn. Make with Mesh, Make with Top Object and Reset with Mesh without Maintain Shape use those bounds. Type that draws nothing keeps its laid-out box, and so does type inside any other object, such as a blend, a repeat or a symbol instance. This is VectorCraft's own rule; envelopes of type made in VectorCraft and in saved files redraw by it. Tests: `crates/engine/src/tests/envelope.rs`, and `tests/envelope_ink.rs` in `vectorcraft-eps`, `vectorcraft-render` and `vectorcraft-effects`. Scores and hours unchanged. +- **Envelopes of type map from the glyphs:** an envelope maps point type, area type, type in groups and layers and type used as a clipping path from the bounds of the glyph outlines, underlines and strikethroughs instead of the type's laid-out box (ascent to descent, or the area's frame), with inline graphics counted by their boxes; rotated type and type in perspective map from their outlines as drawn. Make with Mesh, Make with Top Object and Reset with Mesh without Maintain Shape use those bounds. Type that draws nothing, such as type of only spaces, adds no box while other content in the envelope has one, and keeps its laid-out box when nothing else in the envelope has a box. Type inside any other object, such as a blend, a repeat or a symbol instance, keeps its laid-out box. This is VectorCraft's own rule; envelopes of type made in VectorCraft and in saved files redraw by it. Tests: `crates/engine/src/tests/envelope.rs`, and `tests/envelope_ink.rs` in `vectorcraft-eps`, `vectorcraft-render` and `vectorcraft-effects`. Scores and hours unchanged. - **Top-object envelopes share each side by control polygon:** each piece of a top object's four sides takes a share of the content's width or height in proportion to the length of its control polygon, spread along the piece by its curve parameter (evenly along a segment without handles), where the sides used to share it out by length. Each corner is the nearest anchor within 47.5% of the bounding box from its box corner that has a handle 0.01 pt long or shorter, or whose tangent unit vectors differ by 0.01 or more in x or y and turn out of the outline, or else the outline's nearest point to that box corner. Envelopes opened from `.ai` files keep the corners the file saves, through moves, rotations and reflections. A side whose pieces, from corner to corner, are all one cubic curve split at anchors (each within 0.02 pt of that curve's piece) follows that curve's parameter instead, so splitting such a side's curve without changing its shape leaves how the side shares out the content as it was (corners found from the outline can still move, as one at the outline's nearest point snaps onto an anchor within 0.5% of its segment's parameter), and a top object whose `.ai` mesh saves each side's curve in pieces draws its contents along the whole curve's parameter, as the drawn art in users' files (used locally, never committed) does. A side only partly made of one curve keeps the polygon shares. These are VectorCraft's own rules; existing top-object envelopes redraw by them, and gradient meshes made from a path keep sides measured by length. Tests: `a_top_object_*`, `a_reflected_top_object_keeps_the_corners_a_file_gives`, `a_split_curve_keeps_the_top_object_patch`, `a_top_object_side_of_many_arcs_stays_on_them`, `a_rounded_rectangle_top_object_stays_close_to_the_identity` and `a_rectangle_top_object_is_the_identity` in `live::tests` (`vectorcraft-doc`), `envelope_top_object_shares_a_side_by_control_polygon_length` and `envelope_top_object_keeps_its_look_when_anchors_are_added` in `vectorcraft-engine`, `a_top_object_envelope_opens_live_with_its_outline` and `a_top_object_whose_top_is_one_curve_split_in_thirds_opens_live` in `vectorcraft-eps`. Scores and hours unchanged.