Add Graphic::None and store paint choices as plain color, gradient, and no-paint values

This commit is contained in:
Keavon Chambers
2026-07-20 15:50:06 -07:00
committed by Dennis Kobert
parent d13f926da3
commit a373fad0ca
15 changed files with 294 additions and 110 deletions

View File

@@ -63,10 +63,11 @@ macro_rules! tagged_value {
#[serde(deserialize_with = "core_types::misc::migrate_to_f64_array")] // TODO: Eventually remove this migration document upgrade code
#[serde(alias = "F64Table", alias = "VecF64", alias = "VecF32", alias = "F64Array4")]
F64Array(Vec<f64>),
/// Stored compactly as an `Option<Color>`, materializes as `List<Color>` at runtime via `to_dynany`/`to_any`. Aliases recover legacy on-disk shapes.
#[serde(deserialize_with = "core_types::misc::migrate_to_optional_color")] // TODO: Eventually remove this migration document upgrade code
/// A plain, always-present color. Aliases recover legacy on-disk shapes; a legacy `null` payload (the old "no color")
/// is routed to [`TaggedValue::no_paint`] by `deserialize_tagged_value_with_legacy_migration`.
#[serde(deserialize_with = "core_types::misc::migrate_to_color")] // TODO: Eventually remove this migration document upgrade code
#[serde(alias = "ColorTable", alias = "OptionalColor", alias = "ColorNotInTable")]
Color(Option<Color>),
Color(Color),
/// Stored compactly as a `Gradient`, materializes as a single-row `List<Gradient>` at runtime via `to_dynany`/`to_any`. Aliases recover legacy on-disk shapes.
/// (Old documents that stored a full `Gradient` struct under this same `"Gradient"` tag are routed to `LegacyGradient` by `deserialize_tagged_value_with_legacy_migration`.)
#[serde(deserialize_with = "graphic_types::vector_types::gradient::migrate_to_gradient")] // TODO: Eventually remove this migration document upgrade code
@@ -156,10 +157,7 @@ macro_rules! tagged_value {
let list: List<f64> = values.into_iter().map(core_types::list::Item::new_from_element).collect();
Box::new(list)
}
Self::Color(color) => {
let list: List<Color> = color.into_iter().map(core_types::list::Item::new_from_element).collect();
Box::new(list)
}
Self::Color(color) => Box::new(List::<Color>::new_from_element(color)),
Self::Gradient(stops) => Box::new(List::<Gradient>::new_from_element(stops)),
Self::BrushStrokes(strokes) => {
let list: List<BrushStroke> = strokes.into_iter().map(core_types::list::Item::new_from_element).collect();
@@ -203,10 +201,7 @@ macro_rules! tagged_value {
let list: List<f64> = values.into_iter().map(core_types::list::Item::new_from_element).collect();
Arc::new(list)
}
Self::Color(color) => {
let list: List<Color> = color.into_iter().map(core_types::list::Item::new_from_element).collect();
Arc::new(list)
}
Self::Color(color) => Arc::new(List::<Color>::new_from_element(color)),
Self::Gradient(stops) => Arc::new(List::<Gradient>::new_from_element(stops)),
Self::BrushStrokes(strokes) => {
let list: List<BrushStroke> = strokes.into_iter().map(core_types::list::Item::new_from_element).collect();
@@ -339,7 +334,7 @@ macro_rules! tagged_value {
Self::from_type_or_none(&Type::Concrete(td)).to_edge()
}
Self::F64Array(values) => Ok(leveled_record_value_source(values)),
Self::Color(color) => Ok(leveled_record_value_source(color.into_iter().collect::<Vec<_>>())),
Self::Color(color) => Ok(leveled_record_value_source(vec![color])),
Self::Gradient(stops) => Ok(leveled_record_value_source(vec![stops])),
Self::BrushStrokes(strokes) => Ok(leveled_record_value_source(strokes)),
// =======================
@@ -442,14 +437,14 @@ macro_rules! tagged_value {
// Tries using the default for the tagged value type. If it not implemented, then uses the default used in document_node_types. If it is not used there, then TaggedValue::None is returned.
if name == core_types::normalize_type_name(std::any::type_name::<()>()) { return Some(TaggedValue::None) }
// List-wrapped types need a single-item default with the element's default, not an empty list
if name == core_types::normalize_type_name(std::any::type_name::<List<Color>>()) { return Some(TaggedValue::Color(Some(Color::default()))) }
if name == core_types::normalize_type_name(std::any::type_name::<List<Color>>()) { return Some(TaggedValue::Color(Color::default())) }
if name == core_types::normalize_type_name(std::any::type_name::<List<Gradient>>()) { return Some(TaggedValue::Gradient(Gradient::default())) }
$( if name == core_types::normalize_type_name(std::any::type_name::<$ty>()) { return Some(TaggedValue::$identifier(Default::default())) } )*
if name == core_types::normalize_type_name(std::any::type_name::<List<f64>>()) { return Some(TaggedValue::F64Array(Vec::new())) }
if name == core_types::normalize_type_name(std::any::type_name::<List<BrushStroke>>()) { return Some(TaggedValue::BrushStrokes(Vec::new())) }
// Leveled inputs type by their element; each element name maps to the
// same tagged default as its legacy list form.
if name == core_types::normalize_type_name(std::any::type_name::<Color>()) { return Some(TaggedValue::Color(Some(Color::default()))) }
if name == core_types::normalize_type_name(std::any::type_name::<Color>()) { return Some(TaggedValue::Color(Color::default())) }
if name == core_types::normalize_type_name(std::any::type_name::<Gradient>()) { return Some(TaggedValue::Gradient(Gradient::default())) }
if name == core_types::normalize_type_name(std::any::type_name::<BrushStroke>()) { return Some(TaggedValue::BrushStrokes(Vec::new())) }
if name == core_types::normalize_type_name(std::any::type_name::<Graphic>()) { return Some(TaggedValue::TypeDefault(core_types::descriptor!(List<Graphic>))) }
@@ -722,10 +717,10 @@ impl TaggedValue {
() if ty == TypeId::of::<DVec2>() => to_dvec2(string).map(TaggedValue::DVec2)?,
() if ty == TypeId::of::<bool>() => FromStr::from_str(string).map(TaggedValue::Bool).ok()?,
// `Color` (not in a `List`) is still currently needed by `BlackAndWhiteNode` and `ColorOverlayNode` GPU `shader_node(PerPixelAdjust)` variants
() if ty == TypeId::of::<Color>() => to_color(string).map(|color| TaggedValue::Color(Some(color)))?,
() if ty == TypeId::of::<List<Color>>() => to_color(string).map(|color| TaggedValue::Color(Some(color)))?,
() if ty == TypeId::of::<Color>() => to_color(string).map(TaggedValue::Color)?,
() if ty == TypeId::of::<List<Color>>() => to_color(string).map(TaggedValue::Color)?,
// The Fill and Stroke nodes' paint connectors default to `List<Graphic>`, their first registered implementation row
() if ty == TypeId::of::<List<Graphic>>() => to_color(string).map(|color| TaggedValue::Color(Some(color)))?,
() if ty == TypeId::of::<List<Graphic>>() => to_color(string).map(TaggedValue::Color)?,
() if ty == TypeId::of::<List<Gradient>>() => to_gradient(string).map(TaggedValue::Gradient)?,
() if ty == TypeId::of::<ReferencePoint>() => to_reference_point(string).map(TaggedValue::ReferencePoint)?,
_ => return None,
@@ -743,6 +738,16 @@ impl TaggedValue {
_ => panic!("Passed value is not of type u32"),
}
}
/// The stored form of a paint input's red-slash "no paint" choice: the `List<Graphic>` type default, materializing as an empty paint list.
pub fn no_paint() -> Self {
TaggedValue::TypeDefault(descriptor!(List<Graphic>))
}
/// Whether this is the `List<Graphic>` type default created by [`Self::no_paint`] (and by disconnecting a paint wire).
pub fn is_no_paint(&self) -> bool {
matches!(self, TaggedValue::TypeDefault(td) if *td == descriptor!(List<Graphic>))
}
}
/// Custom deserializer hooked onto `NodeInput::Value::tagged_value` that intercepts removed-variant tags before delegating to `TaggedValue`'s standard derive.
@@ -758,6 +763,7 @@ impl TaggedValue {
/// - `Vector` (or alias `VectorData`):
/// - non-empty → `TaggedValue::VectorModification(<built from first element>)` (the document_migration's Path pass disambiguates this between SVG-import legacy and a discardable modern baked value via the input's `exposed` flag)
/// - empty → `TaggedValue::TypeDefault(descriptor!(List<Vector>))`
/// - `FillChoice` → `TaggedValue::Color` (solid), `TaggedValue::Gradient` (gradient), or `TaggedValue::no_paint()` (none)
///
/// All other tags (including ones with the modern shape) fall through to the standard derived `Deserialize` for `TaggedValue`.
// TODO: Eventually remove this migration document upgrade code
@@ -794,6 +800,31 @@ pub fn deserialize_tagged_value_with_legacy_migration<'de, D: serde::Deserialize
}
return Ok(MemoHash::new(TaggedValue::TypeDefault(descriptor!(List<Vector>))));
}
// The `Color` tag used to carry `Option<Color>`, where a `null` payload (or an empty legacy color table) was the red-slash "no paint" choice
"Color" | "ColorTable" | "OptionalColor" | "ColorNotInTable"
if content.is_null()
|| content
.as_object()
.and_then(|c| c.get("element").or_else(|| c.get("instance")).or_else(|| c.get("instances")))
.and_then(|e| e.as_array())
.is_some_and(|colors| colors.is_empty()) =>
{
return Ok(MemoHash::new(TaggedValue::no_paint()));
}
// The removed `FillChoice` variant decomposes into the plain paint values
"FillChoice" => {
if let Some(payload) = content.as_object() {
if let Some(solid) = payload.get("Solid") {
let color: Color = serde_json::from_value(solid.clone()).map_err(serde::de::Error::custom)?;
return Ok(MemoHash::new(TaggedValue::Color(color)));
}
if let Some(gradient) = payload.get("Gradient") {
let gradient: Gradient = serde_json::from_value(gradient.clone()).map_err(serde::de::Error::custom)?;
return Ok(MemoHash::new(TaggedValue::Gradient(gradient)));
}
}
return Ok(MemoHash::new(TaggedValue::no_paint()));
}
// The `Gradient` tag was reused: it used to carry a full `Gradient` struct (now `LegacyGradient`), and now carries an `Option<Gradient>`.
// Disambiguate by payload shape: a Gradient struct has `start`/`end` keys; a `Gradient` has none of those (it has `position`/`midpoint`/`color`).
"Gradient" if content.as_object().is_some_and(|c| c.contains_key("start") && c.contains_key("end")) => {
@@ -927,7 +958,7 @@ mod leveled_edges {
assert_eq!(edge.ty(), &record_source_type::<f64>());
assert_eq!(edge.layout().depth, 1);
let edge = TaggedValue::Color(Some(Color::default())).to_edge().unwrap();
let edge = TaggedValue::Color(Color::default()).to_edge().unwrap();
assert_eq!(edge.ty(), &record_source_type::<Color>());
assert_eq!(edge.layout().depth, 1);
@@ -973,3 +1004,19 @@ mod record_defaults {
assert_eq!(TaggedValue::from_primitive_string("true", &record_source_type::<bool>()), Some(TaggedValue::Bool(true)));
}
}
#[cfg(test)]
mod paint_default_parsing {
use super::*;
/// Table-era documents stored the red-slash "no paint" fill as an empty color table, which must keep
/// deserializing to [`TaggedValue::no_paint`] rather than collapsing to a transparent color.
#[test]
fn empty_legacy_color_table_deserializes_to_no_paint() {
for payload in [r#"{"ColorTable": {"instances": []}}"#, r#"{"ColorTable": {"element": []}}"#, r#"{"Color": null}"#] {
let mut deserializer = serde_json::Deserializer::from_str(payload);
let value = deserialize_tagged_value_with_legacy_migration(&mut deserializer).expect("The legacy payload should deserialize");
assert!(value.is_no_paint(), "The legacy payload `{payload}` should migrate to the no-paint choice");
}
}
}

View File

@@ -69,7 +69,7 @@ struct LegacyTable<T> {
}
// TODO: Eventually remove this migration document upgrade code
pub fn migrate_to_optional_color<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Option<no_std_types::color::Color>, D::Error> {
pub fn migrate_to_color<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<no_std_types::color::Color, D::Error> {
use no_std_types::color::Color;
use serde::Deserialize;
@@ -81,8 +81,8 @@ pub fn migrate_to_optional_color<'de, D: serde::Deserializer<'de>>(deserializer:
}
Ok(match ColorFormat::deserialize(deserializer)? {
ColorFormat::OptionalColor(color) => color,
ColorFormat::List(list) => list.element.into_iter().next(),
ColorFormat::OptionalColor(color) => color.unwrap_or(Color::TRANSPARENT),
ColorFormat::List(list) => list.element.into_iter().next().unwrap_or(Color::TRANSPARENT),
})
}

View File

@@ -11,6 +11,7 @@ use vector_types::Vector;
/// [`map_groups_to_resident`] re-parks it into a serving arena.
pub fn map_groups_to_owned<'out>(graphic: &Graphic<'_>) -> Graphic<'out> {
match graphic {
Graphic::None => Graphic::None,
Graphic::Group(group) => Graphic::Group(group.copy_out()),
Graphic::Graphic(children) => {
let mut out = List::new();
@@ -78,6 +79,7 @@ unsafe fn deep_repark_graphic(value: &(dyn std::any::Any + Send + Sync), dst: *m
/// what this level newly produced. `None` reports arena exhaustion.
pub fn map_groups_to_persistent<'p>(graphic: &Graphic<'_>, promotion: &core_types::record::Promotion<'p>) -> Option<Graphic<'p>> {
match graphic {
Graphic::None => Some(Graphic::None),
Graphic::Group(group) => group.to_persistent(promotion).map(Graphic::Group),
Graphic::Graphic(children) => {
let mut out = List::new();
@@ -196,7 +198,7 @@ fn graphic_retained_heap(graphic: &Graphic<'_>) -> usize {
Graphic::Text(text) => text.len(),
Graphic::Gradient(gradient) => gradient.len() * size_of::<(f64, Color)>(),
Graphic::Graphic(children) => (0..children.len()).filter_map(|index| children.element(index)).map(graphic_retained_heap).sum(),
Graphic::Group(_) | Graphic::RasterGPU(_) | Graphic::Color(_) => 0,
Graphic::None | Graphic::Group(_) | Graphic::RasterGPU(_) | Graphic::Color(_) => 0,
}
}

View File

@@ -39,6 +39,7 @@ pub(crate) fn run_to_legacy_list<T: Clone + Send + Sync + dyn_any::StaticTypeSiz
/// The graphic with every `Group` converted to its legacy form.
pub fn map_groups_to_legacy<'out>(graphic: &Graphic<'_>) -> Graphic<'out> {
match graphic {
Graphic::None => Graphic::None,
Graphic::Group(group) => group_to_legacy_graphic(group),
Graphic::Graphic(children) => {
let mut out = List::new();

View File

@@ -31,8 +31,11 @@ pub use vector_types::Vector;
/// A leaf holds its element directly; its attributes ride the containing
/// lane. Multi-element content is a [`core_types::record::Group`] run, or
/// transitionally the legacy `Graphic` list.
#[derive(Clone, Debug, CacheHash, PartialEq, DynAny)]
#[derive(Clone, Debug, Default, CacheHash, PartialEq, DynAny)]
pub enum Graphic<'e> {
/// The absence of graphical content, like CSS's `none` keyword: painting it produces nothing.
#[default]
None,
Graphic(List<Graphic<'e>>),
Vector(Vector),
RasterCPU(Raster<CPU>),
@@ -43,12 +46,6 @@ pub enum Graphic<'e> {
Group(core_types::record::Group<'e>),
}
impl Default for Graphic<'_> {
fn default() -> Self {
Self::Graphic(List::new())
}
}
/// A typed legacy list as a legacy graphic list: each item de-tables to a
/// leaf element, keeping its attributes on the containing lane.
pub(in crate::graphic) fn detable_items<'e, T: Clone + Send + Sync + 'static>(list: List<T>, leaf: fn(T) -> Graphic<'e>) -> List<Graphic<'e>> {
@@ -382,6 +379,7 @@ impl<'e> Graphic<'e> {
}
match self {
Graphic::None => true,
Graphic::Graphic(list) => all_clipped(list),
Graphic::Group(group) => group_all_clipped(group),
_ => false,
@@ -397,6 +395,7 @@ impl<'e> Graphic<'e> {
pub fn is_opaque(&self) -> bool {
match self {
Graphic::None => false,
Graphic::Graphic(list) => !list.is_empty() && list.iter_element_values().all(Graphic::is_opaque),
// A bare leaf carries no paint attribute, which rides its lane, so
// nothing here claims opacity.
@@ -410,6 +409,7 @@ impl<'e> Graphic<'e> {
pub fn is_fully_transparent(&self) -> bool {
match self {
Graphic::None => true,
Graphic::Graphic(list) => list.iter_element_values().all(Graphic::is_fully_transparent),
// A bare leaf carries no paint attribute, so only an unstroked
// vector is invisible on its own.
@@ -430,6 +430,7 @@ impl<'e> Graphic<'e> {
/// Whether the graphic holds no content: a leaf always holds its element.
pub fn is_empty(&self) -> bool {
match self {
Graphic::None => true,
Graphic::Graphic(list) => list.is_empty(),
Graphic::Group(group) => group_is_empty(group),
_ => false,
@@ -440,6 +441,7 @@ impl<'e> Graphic<'e> {
impl BoundingBox for Graphic<'_> {
fn bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox {
match self {
Graphic::None => RenderBoundingBox::None,
Graphic::Vector(vector) => BoundingBox::bounding_box(vector, transform, include_stroke),
Graphic::RasterCPU(raster) => raster.bounding_box(transform, include_stroke),
Graphic::RasterGPU(raster) => raster.bounding_box(transform, include_stroke),
@@ -453,6 +455,7 @@ impl BoundingBox for Graphic<'_> {
fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox {
match self {
Graphic::None => RenderBoundingBox::None,
Graphic::Vector(vector) => vector.thumbnail_bounding_box(transform, include_stroke),
Graphic::RasterCPU(raster) => raster.thumbnail_bounding_box(transform, include_stroke),
Graphic::RasterGPU(raster) => raster.thumbnail_bounding_box(transform, include_stroke),
@@ -484,6 +487,7 @@ impl<'e> ListConvert<Graphic<'e>> for Raster<GPU> {
impl RenderComplexity for Graphic<'_> {
fn render_complexity(&self) -> usize {
match self {
Self::None => 0,
Self::Graphic(list) => list.render_complexity(),
Self::Vector(list) => list.render_complexity(),
Self::RasterCPU(list) => list.render_complexity(),

View File

@@ -256,6 +256,7 @@ impl RenderExt for List<Graphic<'_>> {
let gradient_id = render_gradient_paint(&core_types::lane::LeafLane::new(self, 0, gradient), svg_defs, item_transform, element_transform);
format!(r##" {paint_attr}="url(#{gradient_id})""##)
}
Some(Graphic::None) => format!(r#" {paint_attr}="none""#),
Some(Graphic::Vector(_)) | Some(Graphic::RasterCPU(_)) | Some(Graphic::RasterGPU(_)) | Some(Graphic::Graphic(_)) | Some(Graphic::Text(_)) | Some(Graphic::Group(_)) => {
let bounds = if target == PaintTarget::Stroke {
// To prevent a wraparound artefact occurring when the tile boundary and the stroke region are perfectly aligned, the local coordinate is expanded slightly.

View File

@@ -552,6 +552,7 @@ pub trait Render: BoundingBox + RenderComplexity {
impl Render for Graphic<'_> {
fn render_svg(&self, render: &mut SvgRender, render_params: &RenderParams) {
match self {
Graphic::None => (),
Graphic::Graphic(list) => list.render_svg(render, render_params),
Graphic::Vector(vector) => render_vector_svg(&Single(vector), render, render_params),
Graphic::RasterCPU(raster) => render_raster_cpu_svg(&Single(raster), render, render_params),
@@ -565,6 +566,7 @@ impl Render for Graphic<'_> {
fn render_to_vello(&self, scene: &mut Scene, transform: DAffine2, context: &mut RenderContext, render_params: &RenderParams) {
match self {
Graphic::None => (),
Graphic::Graphic(list) => list.render_to_vello(scene, transform, context, render_params),
Graphic::Vector(vector) => render_vector_vello(&Single(vector), scene, transform, context, render_params),
Graphic::RasterCPU(raster) => render_raster_cpu_vello(&Single(raster), scene, transform, render_params),
@@ -590,6 +592,7 @@ impl Render for Graphic<'_> {
fn contains_artboard(&self) -> bool {
match self {
Graphic::None => false,
Graphic::Graphic(list) => list.contains_artboard(),
_ => false,
}
@@ -597,6 +600,7 @@ impl Render for Graphic<'_> {
fn new_ids_from_hash(&mut self, reference: Option<NodeId>) {
match self {
Graphic::None => (),
Graphic::Graphic(list) => list.new_ids_from_hash(reference),
Graphic::Vector(vector) => vector.vector_new_ids_from_hash(reference.map(|id| id.0).unwrap_or_default()),
_ => (),
@@ -660,6 +664,7 @@ fn collect_element_metadata<'a>(
}
match element {
Graphic::None => {}
Graphic::Graphic(list) => collect_graphic_metadata_with(list, reach.nested(), metadata, footprint, element_id),
Graphic::Vector(vector) if reach.applies() => collect_vector_metadata(&PaintOverlay::new(&Single(vector), reach.paint), metadata, footprint, element_id),
Graphic::Vector(vector) => collect_vector_metadata(&Single(vector), metadata, footprint, element_id),
@@ -703,6 +708,7 @@ fn collect_group_row_metadata(group: &Group, metadata: &mut RenderMetadata, elem
fn add_element_upstream_click_targets<'a>(element: &'a Graphic, reach: PaintReach<'a>, click_targets: &mut Vec<ClickTarget>) {
match element {
Graphic::None => (),
Graphic::Graphic(list) => add_graphic_upstream_click_targets_with(list, reach.nested(), click_targets),
Graphic::Vector(vector) if reach.applies() => add_vector_upstream_click_targets(&PaintOverlay::new(&Single(vector), reach.paint), click_targets),
Graphic::Vector(vector) => add_vector_upstream_click_targets(&Single(vector), click_targets),
@@ -715,6 +721,7 @@ fn add_element_upstream_click_targets<'a>(element: &'a Graphic, reach: PaintReac
fn add_element_upstream_outline_targets<'a>(element: &'a Graphic, reach: PaintReach<'a>, outlines: &mut Vec<ClickTarget>) {
match element {
Graphic::None => (),
Graphic::Graphic(list) => add_graphic_upstream_outline_targets_with(list, reach.nested(), outlines),
Graphic::Vector(vector) if reach.applies() => add_vector_upstream_outline_targets(&PaintOverlay::new(&Single(vector), reach.paint), outlines),
Graphic::Vector(vector) => add_vector_upstream_outline_targets(&Single(vector), outlines),
@@ -1563,6 +1570,7 @@ fn render_vector_vello<S: LaneSource<Element = Vector>>(source: &S, scene: &mut
for paint_index in 0..fill_graphic.len() {
let Some(paint) = fill_graphic.element(paint_index) else { continue };
match paint {
Graphic::None => continue,
Graphic::Color(color) => {
let fill = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color());
scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, None, path);
@@ -1643,6 +1651,7 @@ fn render_vector_vello<S: LaneSource<Element = Vector>>(source: &S, scene: &mut
};
match stroke_graphic {
Graphic::None => continue,
Graphic::Color(color) => {
let brush = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color());

View File

@@ -412,6 +412,7 @@ fn flatten_vector_run_into<'a>(out: &mut List<Vector>, level: GraphicLevel<'a>,
let reach = inherited.for_lane(&columns, index);
let composed = transform * level.attr::<TransformAttr>(index);
match element {
Graphic::None => continue,
Graphic::Vector(vector) => push_leaf_vector_row(out, level, index, vector, transform, reach),
Graphic::Graphic(children) => push_union(out, flatten_vector_run(GraphicLevel::Legacy(children), composed, reach.nested())),
Graphic::Group(group) => flatten_group(out, group, composed, reach),