Compare commits

..

27 Commits

Author SHA1 Message Date
bherbruck ec552a5300 Release 0.18.0
The tree is main at c3151fb7f exactly; the previous release commit is kept as a parent so release only moves forward.
2026-10-20 16:28:59 -04:00
bherbruck e0cc740cc3 Release 0.17.0
The tree is main at 511faedcf exactly; the 0.16.x release commits are kept as a parent so release only moves forward.
2026-10-20 11:41:48 -04:00
bherbruck 5de48e916d Release 0.16.2 2026-10-19 00:39:30 +00:00
bherbruck 80af7bbe3e Release 0.16.2
Version 0.16.2: 0.16.1 with the rigid orbit fix (#2753). Same design format (27).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:38:53 +00:00
bherbruck 191e983423 Fix orbit about the picked point (regression in 0.16.0) (#2753)
Orbiting about the point under the cursor (or the origin) turned the camera
about its target and then panned to put the pivot back. The pan works at the
target's depth with the parallel-view scale, so in a perspective view the
pivot slid off its pixel and the model swung about the camera.

The camera now turns rigidly about the pivot: Camera::orbit_about keeps the
pivot's coordinates in the camera's frame and rebuilds the target from the
new orientation, so the pivot stays exactly on its pixel in both projections.
The Shift snap spring and view.navigate's pivot orbit use the same.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit e88779218)
2026-10-19 00:38:52 +00:00
bherbruck 2b9445b049 Release 0.16.1 2026-10-18 20:16:21 +00:00
bherbruck 7ddf48ca16 Release 0.16.1
Version 0.16.1: 0.16.0 with the orbit fix (reverts #2142). Same design format (27).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:12:04 +00:00
bherbruck d2f6848377 Revert #2142: orbit about the picked point like 0.15.0
#2142 made orbiting about the point under the cursor turn the camera in place instead of turning the part about that point. Restore the 0.15.0 behaviour.

This reverts commit cac4bccea8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:12:00 +00:00
bherbruck 0b6d6fecd8 Release 0.16.0 2026-10-18 18:16:11 +00:00
bherbruck fa691707c2 Release 0.15.0 2026-10-17 13:54:24 +00:00
bherbruck b9a55c74e2 Release 0.15.0
Version 0.15.0; the pending design-format changes are folded into format 26.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:53:44 +00:00
bherbruck 2bb9c81069 Release 0.14.0 2026-10-16 09:19:14 +00:00
bherbruck cedb51b8ea Release 0.14.0
Version 0.14.0; the pending design-format changes are folded into format 25.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:19:10 +00:00
bherbruck 66d13059dd Release 0.13.1 2026-10-15 21:56:43 +00:00
bherbruck 800ea40c0d Release 0.13.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:56:35 +00:00
bherbruck 20442eecd3 Agent chat on Windows: start npm agents through their .cmd shim
npm puts a Unix shell script with the bare name (codex-acp,
claude-code-acp, gemini) beside the .cmd shim. The lookup tried the bare
name first, so every npm-installed agent failed with "not a valid Win32
application (os error 193)". Windows now only considers .exe/.cmd/.bat,
and a command saved with the bare path runs the shim beside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 60ea8c69d2)
2026-10-15 21:51:01 +00:00
bherbruck 22d1cd7032 Release 0.13.0 2026-10-15 20:34:13 +00:00
bherbruck 6a0dc18655 Fix the Windows 32-bit and macOS release builds
On 32-bit Windows SetWindowLongPtrW is SetWindowLongW, which takes and
returns i32, so the window-procedure hook didn't compile for i686.
The macOS app bundle now signs the bundled solidcraft-cli before the
bundle itself; codesign refuses a bundle with an unsigned helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 94e76cd97e)
2026-10-15 20:34:12 +00:00
bherbruck e006dbf1af Release 0.13.0
The tree is the 0.13.0 version commit (8241a750fa) on main exactly; the previous release is kept as a parent so release only moves forward.
2026-10-15 19:59:50 +00:00
bherbruck 8241a750fa Release 0.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:59:42 +00:00
bherbruck 41779cf7bb Release 0.12.0: release branch follows main again after the 0.11.1 patch
The tree is main at c36a6f9f exactly; the 0.11.1 patch commit is kept as a parent so release only moves forward.
2026-10-15 13:32:01 +00:00
bherbruck b01b9689c8 Release 0.11.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:41:41 +00:00
bherbruck 4426da6f5d Windows: start Direct3D 12 first, and Vulkan only without a D3D12 GPU (#1190)
* Windows: start Direct3D 12 first, and Vulkan only without a D3D12 GPU

wgpu started Vulkan, D3D12 and OpenGL together and preferred Vulkan, so
every Windows start loaded all installed Vulkan drivers. An old Intel
Vulkan driver (igvk64.dll) crashed 0.10.1 at startup inside the driver.

The app now probes D3D12, then Vulkan, then OpenGL, one backend at a
time, and starts only the first with a hardware adapter (WARP when none
has one). WGPU_BACKEND still wins; graphics.json's "backend" sets the
order by hand; the software renderer (safe mode, the retry) uses D3D12
alone. A new Windows CI job starts the real app under several settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: a test filter YAML parses

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: the startup smoke test writes graphics.json where the app reads it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CI: run the WARP shader tests one at a time

WARP's shader JIT (d3d10warp.dll) crashed twice in 124 runs of the gpu
tests with four test threads, with a null read inside its own optimizer
while several devices compiled compute pipelines at once. Serial runs
did not crash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 95ea05e7c8)
2026-10-14 20:41:37 +00:00
bherbruck 5ead82e627 Release 0.11.0: release branch follows main again after the 0.10.1 patch
The tree is main at cccbe125 exactly; c29b0d2f (0.10.1) is kept as a parent so release only moves forward.
2026-10-14 20:07:41 +00:00
bherbruck c29b0d2f13 Release 0.10.1 2026-10-14 14:41:19 +00:00
bherbruck 480fd8d517 Fix the startup crash on D3D12 with FXC: CPU fallback for the tessellation pipeline, FXC-friendly shader (#1098) (#1099)
* GPU tessellation: a pipeline the device cannot build falls back to CPU meshing instead of crashing (#1098)

Build the tessellation compute pipeline inside error scopes; TessGpu::new returns an error,
and the viewport logs it and meshes bodies on the CPU. A Windows CI job builds the pipeline
with D3D12's FXC on the WARP adapter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tessellation shader: basis functions in one-dimensional local arrays, so D3D12's FXC compiles it (#1098)

FXC has to unroll loops that write an array inside a struct (Basis.n0/n1) or a 2-D array
(ndu) by a counter, and cannot with run-time trip counts (X3511). A2.2 basis functions in a
local 1-D array returned whole; derivatives from the degree p-1 functions; the pole step
offsets computed rather than indexed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:41:16 +00:00
bherbruck 4c09fa5355 Hovered and selected faces light solidly again over GPU-drawn bodies (#1064)
Since the model's mesh went back to the reference mesher (#940), the GPU viewport drew
opaque bodies from their display plans while hover and selection lit faces with the
reference mesh's triangles. Two triangulations of the same face at equal depth fought, so
a hovered face shimmered in wedges instead of lighting whole.

Faces lit over a body drawn from its plan now use the plan's own triangles
(ModelBody::plan_mesh, evaluated once on the CPU), and the highlight pass gets a small
depth bias toward the eye so f32 rounding between the GPU and CPU evaluations of the same
triangles cannot speckle it either.

Test: every face of a box with a pocket and a round boss, hovered and rendered offscreen
through the viewport's real pipelines on the GPU path, lights the same pixels as on the
CPU path (skipped without a GPU adapter).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:56:47 +00:00
13 changed files with 47 additions and 535 deletions
-126
View File
@@ -1,126 +0,0 @@
//! Faces that are picked as one (#3745 follow-up): a modelled thread is thousands of small faces
//! (its flanks, and the pieces of the surface it was cut into), but it is one surface to point at,
//! light and select; so are the pieces of one face an imported file had (a face wrapping all the
//! way round, split in two for the kernel).
//!
//! The groups come from what made the faces, never from their shape: a face made by a Thread
//! feature is named after it (`F<id>:…`), and a piece of a face the thread cut names it among the
//! faces around it (`…[F<id>:…]`). Working the names out for a large body takes a while, so it is
//! done on a worker thread, once per body, and asked for without waiting.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use crate::{Document, FeatureKind, ModelBody};
/// Each face's group (an index into `groups`), and the groups: faces picked together, in order.
#[derive(Debug, Default)]
pub struct FaceGroups {
of_face: Vec<Option<u32>>,
groups: Vec<Arc<Vec<usize>>>,
}
impl FaceGroups {
/// The group of `face` (`None`: it is picked alone).
pub fn group_of(&self, face: usize) -> Option<u32> {
self.of_face.get(face).copied().flatten()
}
/// The faces picked together with `face` (`None`: it is picked alone).
pub fn of(&self, face: usize) -> Option<Arc<Vec<usize>>> {
let g = self.of_face.get(face).copied().flatten()?;
self.groups.get(g as usize).cloned()
}
/// Work out the groups of `b` (the names of its faces are worked out here: slow for a big body).
pub fn of_body(threads: &[u64], b: &ModelBody) -> FaceGroups {
let mut keyed: HashMap<(u8, u64), Vec<usize>> = HashMap::new();
if !threads.is_empty() {
let names = crate::naming::face_names(b);
let marks: Vec<(u64, String, String)> = threads.iter().map(|id| (*id, format!("F{id}:"), format!("[F{id}:"))).collect();
for (i, name) in names.iter().enumerate() {
// Made by the thread, or a piece of a face the thread cut (it names the thread's faces
// around it).
let own = |p: &str, inside: &str| name.starts_with(p) || name.contains(inside) || name.contains(&format!(",{p}"));
if let Some((id, _, _)) = marks.iter().find(|(_, p, inside)| own(p, inside)) {
keyed.entry((0, *id)).or_default().push(i);
}
}
}
// Pieces of one face of an imported file.
if let Some(sources) = b.body.face_sources() {
for (i, s) in sources.iter().enumerate() {
if let Some(solidcraft_kernel::FaceSource::Imported { face, .. }) = s
&& !keyed.values().any(|faces| faces.contains(&i))
{
keyed.entry((1, *face)).or_default().push(i);
}
}
}
let mut out = FaceGroups { of_face: vec![None; b.body.face_count()], groups: Vec::new() };
let mut groups: Vec<Vec<usize>> = keyed.into_values().filter(|faces| faces.len() > 1).collect();
groups.sort();
for faces in groups {
let g = u32::try_from(out.groups.len()).unwrap_or(u32::MAX);
for &f in &faces {
if let Some(slot) = out.of_face.get_mut(f) {
*slot = Some(g);
}
}
out.groups.push(Arc::new(faces));
}
out
}
}
/// The modelled threads of `doc`, by feature id.
pub fn thread_features(doc: &Document) -> Vec<u64> {
doc.features.iter().filter(|f| matches!(f.kind, FeatureKind::Thread { modeled: true, .. })).map(|f| f.id).collect()
}
/// Groups worked out (or being worked out: `None`), by the identity of a body's names and the
/// threads they were grouped by.
type Cache = HashMap<(usize, Vec<u64>), Option<Arc<FaceGroups>>>;
static CACHE: Mutex<Option<Cache>> = Mutex::new(None);
/// Bodies whose groups are kept; the oldest go first.
const KEPT: usize = 256;
/// The groups of `b` in `doc` if they are worked out; otherwise they are worked out on a worker
/// thread (once) and `None` comes back meanwhile. The browser build has no threads: there they
/// are worked out now.
pub fn face_groups_soon(doc: &Document, b: &ModelBody) -> Option<Arc<FaceGroups>> {
let threads = thread_features(doc);
if threads.is_empty() && b.body.face_sources().is_none() {
return Some(Arc::new(FaceGroups::default()));
}
let key = (b.names.id(), threads.clone());
{
let Ok(mut cache) = CACHE.lock() else { return None };
let cache = cache.get_or_insert_with(HashMap::new);
if let Some(entry) = cache.get(&key) {
return entry.clone();
}
if cache.len() >= KEPT {
cache.clear();
}
cache.insert(key.clone(), None);
}
let work = move |b: ModelBody| {
let groups = Arc::new(FaceGroups::of_body(&threads, &b));
if let Ok(mut cache) = CACHE.lock()
&& let Some(slot) = cache.as_mut().and_then(|c| c.get_mut(&key))
{
*slot = Some(groups.clone());
}
groups
};
#[cfg(not(target_arch = "wasm32"))]
{
let body = b.clone();
let work = work.clone();
if std::thread::Builder::new().name("face-groups".into()).spawn(move || drop(work(body))).is_ok() {
return None;
}
}
Some(work(b.clone()))
}
-1
View File
@@ -22,7 +22,6 @@ mod document;
mod eval;
pub mod expr;
pub mod face_anchor;
pub mod face_groups;
mod feature_geometry;
mod feature_refs;
pub use face_anchor::{FaceAnchor, FaceAttachmentStatus};
+4 -9
View File
@@ -348,16 +348,11 @@ fn complete(s: &mut Session, p: &Value) -> Result<Value> {
let text = str_(p, "prefix").unwrap_or("");
// The identifier being typed: trailing letters, digits and underscores.
let word: String = text.chars().rev().take_while(|c| c.is_alphanumeric() || *c == '_').collect::<Vec<_>>().into_iter().rev().collect();
let lower = word.to_ascii_lowercase();
let starts = |n: &str| word.is_empty() || n.to_ascii_lowercase().starts_with(&lower);
// A parameter matches wherever the word appears in its name (`side` finds
// `cab_side_thickness`, #2757); functions, constants and units match from the start.
let within = |n: &str| word.is_empty() || n.to_ascii_lowercase().contains(&lower);
let starts = |n: &str| word.is_empty() || n.to_ascii_lowercase().starts_with(&word.to_ascii_lowercase());
let mut out: Vec<Value> = Vec::new();
let mut rows: Vec<solidcraft_doc::ParamRow> = s.doc.param_rows().into_iter().filter(|r| within(&r.name)).collect();
// Names that start with the word, then the rest; within each, favourites, then user
// parameters, then the others.
rows.sort_by_key(|r| (!starts(&r.name), !r.favorite, r.source != "user"));
let mut rows: Vec<solidcraft_doc::ParamRow> = s.doc.param_rows().into_iter().filter(|r| starts(&r.name)).collect();
// Favourites, then user parameters, then the rest.
rows.sort_by_key(|r| (!r.favorite, r.source != "user"));
for r in rows.into_iter().take(200) {
let v = r.value.map(|v| format!("{v:.4} {}", r.unit).trim().to_string()).unwrap_or_else(|| "error".into());
let mut detail = format!("{} = {v}", r.expression);
-21
View File
@@ -212,27 +212,6 @@ fn favorites_comments_and_completion() {
assert!(c["suggestions"].as_array().unwrap().iter().any(|x| x["text"] == "atan2("), "{c}");
}
/// #2757: a parameter is suggested for a word anywhere in its name, not only at its start;
/// names that start with the word come first. Functions still match from the start.
#[test]
fn completion_matches_parameters_anywhere_in_their_name() {
let mut s = Session::default();
for (name, e) in [("cab_side_thickness", "18 mm"), ("side", "40 mm"), ("door_width", "300 mm")] {
run(&mut s, "parameters.add", json!({"name": name, "expression": e}));
}
let texts = |c: &Value| -> Vec<String> {
c["suggestions"].as_array().unwrap().iter().filter(|x| x["kind"] == "parameter").map(|x| x["text"].as_str().unwrap().to_string()).collect()
};
let c = run(&mut s, "parameters.complete", json!({"prefix": "2 * side"}));
assert_eq!(texts(&c), ["side", "cab_side_thickness"], "the name starting with it first: {c}");
let c = run(&mut s, "parameters.complete", json!({"prefix": "THICK"}));
assert_eq!(texts(&c), ["cab_side_thickness"], "any case: {c}");
assert!(texts(&run(&mut s, "parameters.complete", json!({"prefix": "xyz"}))).is_empty());
// Functions keep matching from the start: `in` is not `sin(`.
let c = run(&mut s, "parameters.complete", json!({"prefix": "in"}));
assert!(!c["suggestions"].as_array().unwrap().iter().any(|x| x["text"] == "sin("), "{c}");
}
#[test]
fn csv_and_json_round_trip() {
let mut s = Session::default();
+2 -34
View File
@@ -504,42 +504,11 @@ impl Mesh {
if self.smooth_seam(e, cos) {
return chain;
}
// The edges by the grid cell of each end, so each step looks only at the edges that end
// near it (#3745: scanning every edge for every edge of a long helical chain held a hover
// for seconds). Cells are twice the join tolerance: two ends within it share a cell or
// touch neighbouring ones.
let cell = tol * 2.0;
let key = |p: Vec3| [(p.x / cell).floor() as i64, (p.y / cell).floor() as i64, (p.z / cell).floor() as i64];
let mut at_end: std::collections::HashMap<[i64; 3], Vec<usize>> = std::collections::HashMap::new();
for (i, p) in self.edges.iter().enumerate() {
for q in [p.first(), p.last()].into_iter().flatten().filter(|q| q.is_finite()) {
let slot = at_end.entry(key(*q)).or_default();
if slot.last() != Some(&i) {
slot.push(i);
}
}
}
let mut in_chain: std::collections::HashSet<usize> = chain.iter().copied().collect();
let mut todo = vec![e];
while let Some(cur) = todo.pop() {
let Some(ce) = ends(cur) else { continue };
// The edges with an end near one of `cur`'s, in edge order (as a scan of every edge
// meets them).
let mut near: Vec<usize> = Vec::new();
for (p, _) in &ce {
let [x, y, z] = key(*p);
for dx in -1..=1 {
for dy in -1..=1 {
for dz in -1..=1 {
near.extend(at_end.get(&[x + dx, y + dy, z + dz]).into_iter().flatten());
}
}
}
}
near.sort_unstable();
near.dedup();
for i in near {
if in_chain.contains(&i) || !self.edge_pickable(i) || self.smooth_seam(i, cos) {
for (i, _) in self.edges.iter().enumerate() {
if chain.contains(&i) || !self.edge_pickable(i) || self.smooth_seam(i, cos) {
continue;
}
let Some(ne) = ends(i) else { continue };
@@ -547,7 +516,6 @@ impl Mesh {
let smooth = ce.iter().any(|(p, d)| ne.iter().any(|(q, dn)| p.dist(*q) < tol && d.dot(-*dn) > cos));
if smooth && chain.len() < 10_000 {
chain.push(i);
in_chain.insert(i);
todo.push(i);
}
}
-26
View File
@@ -164,32 +164,6 @@ fn tangent_chains_follow_smooth_edges() {
assert_eq!(seam.tangent_chain(0, 5f64.to_radians()), vec![0]);
}
/// #3745: the tangent chain of a long helix (a thread's crest, in many edge pieces) is found
/// without scanning every edge for every edge of it: hovering a threaded part's edge looked for
/// its rim this way, and took seconds inside the frame. Every piece joins, in edge order, and
/// a mesh of 3 000 pieces takes well under a frame.
#[test]
fn a_long_helical_tangent_chain_is_found_quickly() {
let pieces = 3_000usize;
let at = |t: f64| Vec3::new(4.0 * t.cos(), 4.0 * t.sin(), 0.2 * t);
let step = 0.05;
let mut m = Mesh::default();
for k in 0..pieces {
let t0 = k as f64 * step * 4.0;
m.edges.push((0..=4).map(|j| at(t0 + j as f64 * step)).collect());
m.edge_faces.push(vec![0]);
}
// The mesh's points (its size sets the join tolerance).
m.positions = m.edges.iter().flatten().copied().collect();
let began = std::time::Instant::now();
let chain = m.tangent_chain(pieces / 2, 2f64.to_radians());
let ms = began.elapsed().as_secs_f64() * 1000.0;
let mut sorted = chain.clone();
sorted.sort_unstable();
assert_eq!(sorted, (0..pieces).collect::<Vec<_>>(), "every piece of the helix is in the chain");
assert!(ms < 200.0, "{ms:.1} ms for {pieces} pieces");
}
#[test]
fn circular_rims_group_only_closed_cocircular_edges() {
let arc = |start: f64, sweep: f64, yscale: f64| -> Vec<Vec3> {
+6 -4
View File
@@ -307,8 +307,8 @@ pub fn component_bodies(app: &SolveApp, id: u64) -> Vec<String> {
/// The eight radial commands, clockwise from the top.
pub fn radial_items(app: &SolveApp) -> Vec<Item> {
let repeat = match &app.last_command {
Some((_, label)) => act("ui.repeat", &format!("Repeat {label}"), "redo").key(crate::keymap::REPEAT_KEY),
None => act("ui.repeat", "Repeat", "redo").key(crate::keymap::REPEAT_KEY).on(false),
Some((_, label)) => act("ui.repeat", &format!("Repeat {label}"), "redo"),
None => act("ui.repeat", "Repeat", "redo").on(false),
};
let delete = act("ui.delete", "Delete", "delete").key("Del").on(!app.session.selection.is_empty());
if app.session.active_sketch.is_some() {
@@ -464,7 +464,7 @@ fn viewport_items(app: &SolveApp) -> Vec<Item> {
let has = |f: fn(&Sel) -> bool| sel.iter().any(f);
let mut v = Vec::new();
if let Some((_, label)) = &app.last_command {
v.push(act("ui.repeat", &format!("Repeat {label}"), "redo").key(crate::keymap::REPEAT_KEY));
v.push(act("ui.repeat", &format!("Repeat {label}"), "redo"));
v.push(Item::sep());
}
// Everything under the cursor, for what lies beneath something else.
@@ -975,7 +975,9 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
}
}
"ui.repeat" => {
app.repeat_last();
if let Some((id, _)) = app.last_command.clone() {
app.start(&id);
}
}
"ui.selectOther" => {
crate::select_other::open(app, at);
-31
View File
@@ -11,12 +11,8 @@ use solidcraft_engine::command_specs;
use crate::SolveApp;
use crate::ui_kit;
/// Repeats the last tool or dialog started (`SolveApp::repeat_last`, #3045).
pub const REPEAT_KEY: &str = "Shift+R";
/// Keys the application handles before any command shortcut.
pub const RESERVED: &[&str] = &[
REPEAT_KEY,
"Escape",
"S",
"V",
@@ -383,33 +379,6 @@ mod tests {
assert_eq!(effective(&b, "solid.move").as_deref(), Some("M"));
assert_eq!(key_name(egui::Key::K, egui::Modifiers { ctrl: true, shift: true, ..Default::default() }), "Ctrl+Shift+K");
}
/// #3045: Shift+R starts the last tool again (here a sketch's Line after Escape put it
/// down), R alone is still the rectangle; the menus show the key on Repeat, and no command
/// can take it.
#[test]
fn shift_r_repeats_the_last_tool() {
let mut h = crate::scenario::Harness::new();
h.app.home.open = false;
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
h.frames(2);
h.step(&json!({"start": "sketch.line"})).unwrap();
h.step(&json!({"key": "Escape"})).unwrap();
h.frames(1);
assert!(h.app.tool.is_none());
h.step(&json!({"key": "R", "shift": true})).unwrap();
h.frames(1);
assert_eq!(h.app.tool.as_ref().map(|t| t.cmd.to_string()).as_deref(), Some("sketch.line"), "Shift+R starts Line again");
h.step(&json!({"key": "Escape"})).unwrap();
h.step(&json!({"key": "R"})).unwrap();
h.frames(1);
let rectangle = h.app.tool.as_ref().map(|t| t.cmd.to_string()).unwrap_or_default();
assert!(rectangle.starts_with("sketch.rectangle"), "R alone: {rectangle}");
let repeat = crate::context_menu::radial_items(&h.app).into_iter().find(|i| i.id == "ui.repeat").unwrap();
assert_eq!(repeat.shortcut, REPEAT_KEY);
let mut a = app();
assert!(bind(&mut a, "solid.extrude", REPEAT_KEY, true).is_err(), "Shift+R is the app's");
}
}
#[cfg(test)]
-8
View File
@@ -810,14 +810,6 @@ impl SolveApp {
workspace::sync_context(self);
}
/// Start the last tool or dialog started again (Repeat in the menus, Shift+R; #3045).
/// `false` when none was started yet.
pub fn repeat_last(&mut self) -> bool {
let Some((id, _)) = self.last_command.clone() else { return false };
self.start(&id);
true
}
/// Start a command the way a toolbar click does: interactive tools and dialogs for commands
/// that have them, otherwise run it with defaults.
pub fn start(&mut self, id: &str) {
+5 -22
View File
@@ -634,12 +634,11 @@ pub(crate) fn suggestions_for(ctx: &egui::Context, word: &str, explicit: bool) -
return Vec::new();
}
let w = word.to_ascii_lowercase();
let offered = |s: &&Suggestion| explicit || s.text.trim_end_matches('(') != word;
let starts = |s: &&Suggestion| s.text.to_ascii_lowercase().starts_with(&w);
// A parameter is also offered for a word anywhere in its name (`side` finds
// `cab_side_thickness`, #2757), after the names that start with it.
let within = |s: &&Suggestion| s.kind == "parameter" && !starts(s) && s.text.to_ascii_lowercase().contains(&w);
all.iter().filter(|s| starts(s) && offered(s)).chain(all.iter().filter(|s| within(s) && offered(s))).take(8).cloned().collect()
all.iter()
.filter(|s| s.text.to_ascii_lowercase().starts_with(&w) && (explicit || s.text.trim_end_matches('(') != word))
.take(8)
.cloned()
.collect()
}
/// Offer keyboard and pointer completions under a focused expression box.
@@ -673,22 +672,6 @@ mod tests {
assert!(check(&mut app, "width + 1 deg", "mm").is_err());
}
/// #2757: typing part of a parameter's name offers it, not only its start; names that start
/// with the word come first, and functions still match from the start.
#[test]
fn a_parameter_is_offered_for_any_part_of_its_name() {
let ctx = egui::Context::default();
let mut app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
for (name, e) in [("cab_side_thickness", "18 mm"), ("sides", "4")] {
let _ = app.run("parameters.add", json!({"name": name, "expression": e}));
}
publish(&mut app, &ctx);
let texts = |w: &str| suggestions(&ctx, w).into_iter().map(|s| s.text).collect::<Vec<_>>();
assert_eq!(texts("side"), ["sides", "cab_side_thickness"]);
assert_eq!(texts("Thick"), ["cab_side_thickness"]);
assert!(!texts("in").iter().any(|t| t == "sin("), "functions match from the start");
}
/// A favourite is listed twice, under Favorites and in its own table; typing into either of its
/// expression boxes enters each key once (#1767: `1.1` came out `1.11.1`).
#[test]
-64
View File
@@ -2173,67 +2173,3 @@ fn hovering_a_large_body_does_not_wait_for_its_pick_index() {
assert!(matches!(h.app.viewport.hover, Some(crate::viewport::Hit::Face { .. })), "{:?}", h.app.viewport.hover);
assert!(hover_ms < index_ms / 3.0, "the first hover took {hover_ms:.1} ms; making the index takes {index_ms:.1} ms");
}
/// A modelled thread is thousands of small faces but one surface to point at: the pointer over
/// it lands on a face (not on the vertices and edges between the thread's own faces), the face
/// lights up with the whole thread, and a click selects the thread as one item, which stands for
/// every one of its faces when it is shown and when a command takes faces. No hover waits for the
/// thread's faces to be worked out.
#[test]
fn a_modelled_thread_hovers_and_selects_as_one_surface() {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.cylinder", json!({"diameter": 8, "height": 16, "base": [0, 0, 0], "axis": [0, 0, 1]})).unwrap();
h.app.run("solid.thread", json!({"face": [4, 0, 8], "designation": "M8", "length": 10, "modeled": true})).unwrap();
let st = h.app.session.world_state();
let b = &st.bodies[0];
let names = solidcraft_engine::doc::naming::face_names(b);
let thread = h.app.session.doc.features.iter().find(|f| matches!(f.kind, solidcraft_engine::doc::FeatureKind::Thread { .. })).unwrap().id;
let made = names.iter().filter(|n| n.starts_with(&format!("F{thread}:"))).count();
assert!(made > 500, "{made} thread faces");
// Close in on the threaded part, side on.
h.app.cam.target = Vec3::new(0.0, 0.0, 5.0);
h.app.cam.distance = 30.0;
h.frames(3);
let mut slowest = 0.0f64;
let mut grouped = None;
for k in 0..40 {
let at = h.at(&json!({"world": [4.0 * (0.3 * k as f64).cos(), 4.0 * (0.3 * k as f64).sin(), 2.0 + (k % 6) as f64]})).unwrap();
let t = std::time::Instant::now();
h.step(&json!({"move": at})).unwrap();
slowest = slowest.max(t.elapsed().as_secs_f64() * 1000.0);
if let Some(crate::viewport::Hit::Face { body, index, point }) = h.app.viewport.hover.clone() {
let sel = Sel::Face { body, index, point };
let pieces = crate::viewport::grouped_selection(&h.app.session, &sel);
if pieces.len() >= made {
grouped = Some(sel);
break;
}
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(slowest < 250.0, "a hover took {slowest:.0} ms");
let sel = grouped.expect("hovering the thread lands on its faces, and they are worked out as one surface");
// Over the thread, the pointer finds the surface, not the vertices and edges inside it.
let faces = (0..12)
.filter(|k| {
let a = 0.5 * *k as f64;
let at = h.at(&json!({"world": [4.0 * a.cos(), 4.0 * a.sin(), 3.0 + (*k % 4) as f64]})).unwrap();
h.step(&json!({"move": at})).unwrap();
matches!(h.app.viewport.hover, Some(crate::viewport::Hit::Face { .. }))
})
.count();
assert!(faces >= 9, "{faces} of 12 hovers over the thread landed on its surface");
// A click selects it as one item, which stands for every face of the thread.
let point = match &sel {
Sel::Face { point, .. } => *point,
_ => Vec3::ZERO,
};
let at = h.at(&json!({"world": [point.x, point.y, point.z]})).unwrap();
let t = std::time::Instant::now();
h.step(&json!({"click": at})).unwrap();
let click_ms = t.elapsed().as_secs_f64() * 1000.0;
assert_eq!(h.app.session.selection.len(), 1, "one item: {:?}", h.app.session.selection.len());
let pieces = crate::viewport::grouped_selection(&h.app.session, &h.app.session.selection[0]);
assert!(pieces.len() >= made, "the thread stands for {} faces of {made}", pieces.len());
assert!(click_ms < 250.0, "the click took {click_ms:.0} ms");
}
-3
View File
@@ -1845,9 +1845,6 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
app.start("ui.palette");
}
Key::V => crate::context_menu::toggle_visibility(app),
Key::R if mods.shift && !mods.alt => {
app.repeat_last();
}
Key::F2 => {
let at = ctx.input(|i| i.pointer.latest_pos()).unwrap_or(ctx.content_rect().center());
crate::context_menu::rename_selection(app, at);
+30 -186
View File
@@ -1365,41 +1365,12 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool,
// The edges seen near the cursor, when the GPU pick buffer has them for this view (Select
// Other wants hidden ones too).
let seen = if all { None } else { crate::pick_buffer::edges_near(app, proj, pos, &st) };
// The face under the pointer wins over the vertices and edges inside it when they are not
// the boundary the pointer means: the edges between faces picked as one with it (a modelled
// thread's thousands of faces), and, when the face is small on screen (a finely faceted
// surface), any vertex or edge further than a couple of points away.
let inside = match bestf.as_ref().map(|(_, h)| h) {
Some(Hit::Face { body, index, .. }) if !all => st.body(body).map(|b| {
let m = b.mesh();
let groups = solidcraft_engine::doc::face_groups::face_groups_soon(&app.session.doc, b);
let group = groups.as_ref().and_then(|g| g.group_of(*index));
// Small on screen: only worth the look for a face picked alone.
let small = group.is_none() && {
let mut lo = egui::pos2(f32::INFINITY, f32::INFINITY);
let mut hi = egui::pos2(f32::NEG_INFINITY, f32::NEG_INFINITY);
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
if *f as usize == *index {
for p in m.tri(t).into_iter().flatten().filter_map(|p| proj.to_screen(p)) {
lo = lo.min(p);
hi = hi.max(p);
}
}
}
lo.x <= hi.x && (hi - lo).length() < 21.0
};
(body.clone(), groups.zip(group), small)
}),
_ => None,
};
let mut bestv = Near::new(all);
let mut beste = Near::new(all);
for (i, b) in st.bodies.iter().enumerate() {
if !app.session.body_visible(&b.name) {
continue;
}
let inside = inside.as_ref().filter(|(body, _, _)| *body == b.name);
let (vertex_px, edge_px) = if inside.is_some_and(|(_, _, small)| *small) { (2.0, 2.0) } else { (7.0, 6.0) };
// Only bodies whose outline on screen is near the cursor can have an edge under it.
if let Some((_, bb)) = bounds.get(i)
&& screen_box(proj, bb, 8.0).is_some_and(|r| !r.contains(pos))
@@ -1420,18 +1391,12 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool,
if !m.edge_pickable(ei) {
continue;
}
// Between two faces picked as one with the face under the pointer: not a boundary.
if let Some((_, Some((groups, group)), _)) = inside
&& m.edge_faces.get(ei).is_some_and(|fs| !fs.is_empty() && fs.iter().all(|f| groups.group_of(*f as usize) == Some(*group)))
{
continue;
}
if last_edge != Some(ei) {
last_edge = Some(ei);
for v in [e.first(), e.last()].into_iter().flatten() {
if let Some(sp) = proj.to_screen(*v) {
let dd = sp.distance(pos);
if dd < vertex_px && visible(*v) && bestv.wants(dd) {
if dd < 7.0 && visible(*v) && bestv.wants(dd) {
bestv.offer(dd, Hit::Vertex { body: b.name.clone(), point: *v });
}
}
@@ -1442,7 +1407,7 @@ fn pick_hits(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool,
let dd = seg_dist(pos, a, c);
// Visible where the cursor is: the edge's point under the cursor, not an end or
// the middle, which can lie deeper than the face the cursor is on.
if dd < edge_px && beste.wants(dd) && visible(ray_segment_closest(o, d, *p0, *p1)) {
if dd < 6.0 && beste.wants(dd) && visible(ray_segment_closest(o, d, *p0, *p1)) {
beste.offer(dd, Hit::Edge { body: b.name.clone(), index: ei, mid: polyline_mid(e) });
}
}
@@ -1867,7 +1832,6 @@ fn edge_of(m: &solidcraft_engine::geom::Mesh, index: usize, point: Vec3) -> Opti
m.edges.iter().min_by(|a, b| near(a).total_cmp(&near(b))).filter(|e| near(e) < tol * 1e3)
}
#[cfg(test)]
fn face_tris(sc: &mut GpuScene, m: &solidcraft_engine::geom::Mesh, face: usize, col: [u8; 4], lit: bool) {
mesh_tris(sc, m, col, lit, |f| f as usize == face);
}
@@ -1902,25 +1866,9 @@ fn edge_lines(sc: &mut GpuScene, e: &[Vec3], core: Color32, halo: Color32, w: f3
}
/// The outline of a highlighted face where the model hides it (dimmed).
/// Whether an edge (between `faces`) lies inside one thread's faces: no boundary to draw or pick.
fn inside_group(groups: Option<&solidcraft_engine::doc::face_groups::FaceGroups>, faces: &[u32]) -> bool {
let Some(groups) = groups else { return false };
let mut of = faces.iter().map(|f| groups.group_of(*f as usize));
match of.next() {
Some(Some(g)) => faces.len() > 1 && of.all(|x| x == Some(g)),
_ => false,
}
}
fn hidden_faces_outline(
sc: &mut GpuScene,
m: &solidcraft_engine::geom::Mesh,
faces: &std::collections::HashSet<usize>,
groups: Option<&solidcraft_engine::doc::face_groups::FaceGroups>,
col: Color32,
) {
fn hidden_face_outline(sc: &mut GpuScene, m: &solidcraft_engine::geom::Mesh, face: usize, col: Color32) {
for (ei, e) in m.edges.iter().enumerate() {
let on = m.edge_faces.get(ei).is_some_and(|f| f.iter().any(|x| faces.contains(&(*x as usize))) && !inside_group(groups, f));
let on = m.edge_faces.get(ei).is_some_and(|f| f.iter().any(|x| *x as usize == face));
if on && m.edge_pickable(ei) {
for p in e.windows(2) {
sc.hidden_line(p[0].to_f32(), p[1].to_f32(), c4(col.gamma_multiply(0.45)), 1.4);
@@ -1988,32 +1936,8 @@ fn highlight_geometry_key(app: &SolveApp) -> u64 {
let selected = app.highlighted();
app.session.revision.hash(&mut h);
(std::sync::Arc::as_ptr(&crate::open_meshing::shown_world(app)) as usize).hash(&mut h);
// A thread selected is thousands of faces: they are hashed by body and index, not written
// out every frame.
selected.len().hash(&mut h);
for item in &selected {
match item {
Sel::Face { body, index, .. } => (0u8, body, index).hash(&mut h),
other => serde_json::to_string(other).unwrap_or_default().hash(&mut h),
}
}
// A face hovered stands for the faces lit with it: moving over them (or within one) lights
// the same, and the highlight is not built again.
let group = match &app.viewport.hover {
Some(Hit::Face { body, index, .. }) => app
.session
.world_state()
.body(body)
.and_then(|b| solidcraft_engine::doc::face_groups::face_groups_soon(&app.session.doc, b))
.and_then(|g| g.of(*index))
.and_then(|g| g.first().copied())
.map(|first| (body.clone(), first)),
_ => None,
};
match group {
Some(group) => group.hash(&mut h),
None => format!("{:?}", app.viewport.hover).hash(&mut h),
}
serde_json::to_string(&selected).unwrap_or_default().hash(&mut h);
format!("{:?}", app.viewport.hover).hash(&mut h);
app.viewport.hover_feature.hash(&mut h);
app.viewport.hover_bodies.hash(&mut h);
// Which mesh lights a face follows how the model draws its body (see `shown_mesh`).
@@ -2069,15 +1993,9 @@ pub(crate) fn build_highlight(app: &SolveApp) -> GpuScene {
// Faces lit over the model use the triangles the model shows them with.
let shown = |b: &solidcraft_engine::doc::ModelBody| shown_mesh(app, b, &active_bodies);
let mut sel = Vec::new();
// Faces by body and index: a selected thread stands for thousands of them.
let mut seen_faces: std::collections::HashSet<(String, usize)> = std::collections::HashSet::new();
for item in app.highlighted() {
for piece in displayed_selection(app, &item) {
let fresh = match &piece {
Sel::Face { body, index, .. } => seen_faces.insert((body.clone(), *index)),
_ => !sel.iter().any(|x| crate::selection::same_item(x, &piece)),
};
if fresh {
if !sel.iter().any(|x| crate::selection::same_item(x, &piece)) {
sel.push(piece);
}
}
@@ -2158,31 +2076,16 @@ pub(crate) fn build_highlight(app: &SolveApp) -> GpuScene {
return sc;
}
let mut feedback = GpuScene::default();
// Selected faces, a pass per body however many of its faces are selected (a modelled
// thread is thousands).
let mut faces_of: Vec<(&str, std::collections::HashSet<usize>)> = Vec::new();
for x in &sel {
if let Sel::Face { body, index, .. } = x {
match faces_of.iter_mut().find(|(b, _)| *b == body.as_str()) {
Some((_, faces)) => {
faces.insert(*index);
}
None => faces_of.push((body.as_str(), std::collections::HashSet::from([*index]))),
}
}
}
for (body, faces) in &faces_of {
// A live preview stands in for the body: its old faces would fight with it.
if let Some(b) = st.body(body).filter(|_| app.session.body_visible(body) && !app.preview.effective_replaced().iter().any(|r| r == *body)) {
mesh_tris(&mut feedback, &shown(b), c4(t.sel_face), false, |f| faces.contains(&(f as usize)));
let groups = solidcraft_engine::doc::face_groups::face_groups_soon(&s.doc, b);
hidden_faces_outline(&mut feedback, &drawn_mesh(app, b), faces, groups.as_deref(), t.sel_edge);
}
}
// Selected things.
for x in &sel {
match x {
Sel::Face { .. } => {}
Sel::Face { body, index, .. } => {
// A live preview stands in for the body: its old faces would fight with it.
if let Some(b) = st.body(body).filter(|_| app.session.body_visible(body) && !app.preview.effective_replaced().contains(body)) {
face_tris(&mut feedback, &shown(b), *index, c4(t.sel_face), false);
hidden_face_outline(&mut feedback, &drawn_mesh(app, b), *index, t.sel_edge);
}
}
Sel::Body { name } => {
if let Some(b) = st.body(name)
&& app.session.body_visible(name)
@@ -2308,21 +2211,12 @@ pub(crate) fn build_highlight(app: &SolveApp) -> GpuScene {
let k = t.hover_face_lift;
let c = body_rgb(app, body);
let m = drawn_mesh(app, b);
// The face with the faces picked as one with it (a modelled thread), in one pass.
let patch = face_patch(s, b, &m, *index);
let mut in_patch = vec![false; m.tri_face.iter().map(|f| *f as usize + 1).max().unwrap_or(0).max(*index + 1)];
for &f in &patch {
if let Some(slot) = in_patch.get_mut(f) {
*slot = true;
}
}
let patch = m.surface_patch(*index);
let lit = shown(b);
let col = [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255];
mesh_tris(&mut feedback, &lit, col, true, |f| in_patch.get(f as usize).copied().unwrap_or(false));
// Its edges, but not those between two faces of one thread: no boundary there.
let groups = solidcraft_engine::doc::face_groups::face_groups_soon(&s.doc, b);
let lit_face = |f: &u32| in_patch.get(*f as usize).copied().unwrap_or(false);
for (e, _) in m.edge_faces.iter().enumerate().filter(|(_, fs)| fs.iter().any(lit_face) && !inside_group(groups.as_deref(), fs)) {
for &f in &patch {
face_tris(&mut feedback, &lit, f, [c.0.saturating_add(k), c.1.saturating_add(k), c.2.saturating_add(k), 255], true);
}
for (e, _) in m.edge_faces.iter().enumerate().filter(|(_, fs)| fs.iter().any(|f| patch.contains(&(*f as usize)))) {
if let Some(p) = m.edges.get(e)
&& m.edge_pickable(e)
{
@@ -3243,49 +3137,12 @@ pub(crate) fn picked_selection(s: &solidcraft_engine::Session, sel: &Sel) -> Vec
{
return letters.into_iter().map(|index| Sel::Profile { sketch: *sketch, index }).collect();
}
selection_pieces(s, sel)
grouped_selection(s, sel)
}
/// Expand a visible rim or surface into the kernel pieces a command needs. The original
/// face pick keeps its point (used by tools such as Hole and construction geometry).
/// The faces picked as one with `face` of `b` (`m` its mesh): the pieces of its surface joined
/// across hidden seams, and every face of a modelled thread (or of one imported face) it is part
/// of. The thread's faces are worked out on a worker; until then the face goes with its surface.
fn face_patch(s: &solidcraft_engine::Session, b: &solidcraft_engine::doc::ModelBody, m: &solidcraft_engine::geom::Mesh, face: usize) -> Vec<usize> {
face_pieces(s, b, m, face, true)
}
/// [`face_patch`], with the faces of the thread it is part of when `groups`.
fn face_pieces(
s: &solidcraft_engine::Session,
b: &solidcraft_engine::doc::ModelBody,
m: &solidcraft_engine::geom::Mesh,
face: usize,
groups: bool,
) -> Vec<usize> {
let mut patch = m.surface_patch(face);
if !groups {
return patch;
}
if let Some(group) = solidcraft_engine::doc::face_groups::face_groups_soon(&s.doc, b).and_then(|g| g.of(face)) {
let mut seen: std::collections::HashSet<usize> = patch.iter().copied().collect();
patch.extend(group.iter().copied().filter(|f| seen.insert(*f)));
}
patch
}
pub(crate) fn grouped_selection(s: &solidcraft_engine::Session, sel: &Sel) -> Vec<Sel> {
pieces(s, sel, true)
}
/// What a click or a box puts in the selection for `sel`: as [`grouped_selection`], but a face
/// of a modelled thread stays one item (the thread's thousands of faces are what it stands for
/// when shown or given to a command).
pub(crate) fn selection_pieces(s: &solidcraft_engine::Session, sel: &Sel) -> Vec<Sel> {
pieces(s, sel, false)
}
fn pieces(s: &solidcraft_engine::Session, sel: &Sel, groups: bool) -> Vec<Sel> {
let st = s.world_state();
match sel {
Sel::Edge { body, index, .. } => {
@@ -3301,28 +3158,21 @@ fn pieces(s: &solidcraft_engine::Session, sel: &Sel, groups: bool) -> Vec<Sel> {
Sel::Face { body, index, .. } => {
if let Some(b) = st.body(body) {
let m = b.mesh();
let mut patch = face_pieces(s, b, &m, *index, groups);
let mut patch = m.surface_patch(*index);
patch.sort_by_key(|i| usize::from(i != index));
// A point on each face, from one pass over the triangles.
let wanted: std::collections::HashSet<usize> = patch.iter().copied().collect();
let mut point: std::collections::HashMap<usize, Vec3> = std::collections::HashMap::new();
for (t, f) in m.triangles.iter().zip(&m.tri_face) {
let f = *f as usize;
if f != *index
&& !point.contains_key(&f)
&& wanted.contains(&f)
&& let Some([a, b, c]) = m.tri(t)
{
point.insert(f, (a + b + c) / 3.0);
}
}
return patch
.into_iter()
.filter_map(|i| {
if i == *index {
return Some(sel.clone());
}
point.get(&i).map(|p| Sel::Face { body: body.clone(), index: i, point: *p })
m.triangles.iter().zip(&m.tri_face).find_map(|(t, f)| {
if *f as usize != i {
return None;
}
let [a, b, c] = m.tri(t)?;
Some(Sel::Face { body: body.clone(), index: i, point: (a + b + c) / 3.0 })
})
})
.collect();
}
@@ -3497,15 +3347,9 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) {
}
}
let mut grouped = Vec::new();
// Faces by body and index: a box over a thread takes thousands of them.
let mut faces: std::collections::HashSet<(String, usize)> = std::collections::HashSet::new();
for item in out {
for piece in selection_pieces(&app.session, &item) {
let fresh = match &piece {
Sel::Face { body, index, .. } => faces.insert((body.clone(), *index)),
_ => !grouped.iter().any(|x| crate::selection::same_item(x, &piece)),
};
if fresh {
for piece in grouped_selection(&app.session, &item) {
if !grouped.iter().any(|x| crate::selection::same_item(x, &piece)) {
grouped.push(piece);
}
}