graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: the spreadsheet shows the point groups first, as 1 and 0
They were g: columns after every attribute, off the right of the pane,
and blank for a point outside the group.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CLAUDE.md | 9 +++++++++
src/app.rs | 31 ++++++++++++++++---------------
src/main.rs | 25 ++++++++++++++-----------
3 files changed, 39 insertions(+), 26 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 9136ba0..69b7f48 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1682,6 +1682,15 @@ wears and at Group Marker Size.
`selected_spreadsheet_rows_are_marked_in_the_scene` drives it by pointer.
+**The point groups are the spreadsheet's first columns** after the
+point's number (`geometry_to_spreadsheet_data`, since 2026-09-29):
+`group:<name>`, 1 for a member and 0 for the rest. They were `g:` columns
+after every attribute, the thirteenth column of a sphere's table and off
+the right of any pane, and blank for a point outside the group — so a
+group of one point among five hundred was a column that looked empty.
+Sorting the column descending brings the members to the top. Only POINT
+groups: a row is a point, and the table has no primitive rows.
+
**What reads a selected node for display reads it as the scene shows it**
(`geometry::node_geometry_as_shown`, the same day): the spreadsheet's
rows, the markers on them and the selected group's. A node inside a
diff --git a/src/app.rs b/src/app.rs
index abd92da..32a9e22 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -5883,15 +5883,24 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
// 362 places — and the row number meant nothing a user could point at.
// It is now the point index, which is also what the Point Numbers overlay
// draws.
- let mut headers = vec![
- "Point".to_string(),
+ // The groups stand FIRST after the point's number, a column each,
+ // `group:<name>`, 1 for a member and 0 for the rest. Until 2026-09-29
+ // they were `g:` columns after every attribute — the thirteenth column
+ // of a sphere's table, off the right of any pane — and blank for a
+ // point not in the group, so a group of one point among five hundred
+ // was a column that looked empty. Sorting the column, descending,
+ // brings the members to the top.
+ let groups = geom.points().group_names();
+ let mut headers = vec!["Point".to_string()];
+ headers.extend(groups.iter().map(|g| format!("group:{}", g)));
+ headers.extend([
"Pos.x".to_string(),
"Pos.y".to_string(),
"Pos.z".to_string(),
"Col.r".to_string(),
"Col.g".to_string(),
"Col.b".to_string(),
- ];
+ ]);
// Columnar storage means the columns are known up front, from the store
// rather than from a scan of every element's map. `names()` is sorted, so
@@ -5926,11 +5935,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
}
}
- let groups = geom.points().group_names();
- for g in &groups {
- headers.push(format!("g:{}", g));
- }
-
// Detail attributes ride along as `d:` columns, constant down the table —
// which is what a detail attribute IS. Without this the Analysis node
// would write its answers somewhere nothing could show them.
@@ -5956,15 +5960,16 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
for p in 0..geom.num_points() {
let pos = geom.positions()[p];
let col = geom.color(p);
- let mut row = vec![
- p.to_string(),
+ let mut row = vec![p.to_string()];
+ row.extend(groups.iter().map(|g| if geom.points().in_group(g, p) { "1" } else { "0" }.to_string()));
+ row.extend([
format!("{:.4}", pos[0]),
format!("{:.4}", pos[1]),
format!("{:.4}", pos[2]),
format!("{:.4}", col[0]),
format!("{:.4}", col[1]),
format!("{:.4}", col[2]),
- ];
+ ]);
for (name, ty) in &attribs {
// A column covers its whole class, so there is no "this element
@@ -5985,10 +5990,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
}
}
- for g in &groups {
- row.push(if geom.points().in_group(g, p) { "1".to_string() } else { String::new() });
- }
-
for (name, ty) in &detail {
match geom.detail().value(name, 0) {
Some(crate::detail::AttribValue::Float(f)) => row.push(format!("{:.4}", f)),
diff --git a/src/main.rs b/src/main.rs
index 660b748..e7592d7 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -5248,24 +5248,27 @@ mod tests {
assert_eq!(
headers,
vec![
- "Point", "Pos.x", "Pos.y", "Pos.z", "Col.r", "Col.g", "Col.b",
- "ID", "UV.x", "UV.y", "g:pinned", "d:mass_max~",
+ "Point", "group:pinned", "Pos.x", "Pos.y", "Pos.z", "Col.r", "Col.g", "Col.b",
+ "ID", "UV.x", "UV.y", "d:mass_max~",
]
);
assert_eq!(rows.len(), 2, "one row per point");
+ assert!(rows.iter().all(|r| r.len() == headers.len()), "a cell a column");
assert_eq!(rows[0][0], "0");
- assert_eq!(rows[0][1], "1.0000"); // Pos.x
- assert_eq!(rows[0][4], "1.0000"); // Col.r
- assert_eq!(rows[0][7], "42"); // ID, an integer and printed as one
- assert_eq!(rows[0][8], "0.1000"); // UV.x
- assert_eq!(rows[0][10], "", "point 0 is not in the group");
+ // The groups stand beside the point's number, where they are seen
+ // without scrolling, and say 0 as plainly as 1.
+ assert_eq!(rows[0][1], "0", "point 0 is not in the group");
+ assert_eq!(rows[0][2], "1.0000"); // Pos.x
+ assert_eq!(rows[0][5], "1.0000"); // Col.r
+ assert_eq!(rows[0][8], "42"); // ID, an integer and printed as one
+ assert_eq!(rows[0][9], "0.1000"); // UV.x
assert_eq!(rows[1][0], "1");
- assert_eq!(rows[1][1], "4.0000");
- assert_eq!(rows[1][7], "0", "unwritten is the type's zero, not a dash");
- assert_eq!(rows[1][9], "0.4000"); // UV.y
- assert_eq!(rows[1][10], "1", "point 1 is in the group");
+ assert_eq!(rows[1][1], "1", "point 1 is in the group");
+ assert_eq!(rows[1][2], "4.0000");
+ assert_eq!(rows[1][8], "0", "unwritten is the type's zero, not a dash");
+ assert_eq!(rows[1][10], "0.4000"); // UV.y
assert_eq!(rows[0][11], "9.5000");
assert_eq!(rows[1][11], "9.5000", "a detail value repeats down the column");
// The trailing ~ says this one resets at every step boundary.